SQL Server for .NET developers comes down to decisions no ORM makes for you: which indexes exist, which plan the optimizer picks, and how concurrent transactions behave under load. This guide is for engineers who already use EF Core, Dapper or ADO.NET. You will learn to design indexes, read execution plans, tame parameter sniffing, choose isolation levels, survive deadlocks, tune Microsoft.Data.SqlClient pooling and use the SQL Server 2025 vector and JSON features.
What SQL Server Means for a .NET Developer in 2026#
One engine ships in several forms. SQL Server 2022 is still common, SQL Server 2025 (17.x) became generally available on November 18, 2025, and Azure SQL Database and Managed Instance run a continuously updated engine as a service. T-SQL skills transfer, but defaults for isolation, locking and connectivity differ in ways that cause real bugs.
On the client, everything flows through Microsoft.Data.SqlClient, the provider beneath EF Core and Dapper. Version 6.0 added SqlJson, 6.1 added SqlVector<T>, 7.0 (March 2026) moved Microsoft Entra ID authentication into the separate Microsoft.Data.SqlClient.Extensions.Azure package, and 7.1 (September 2026) supports .NET Framework 4.6.2+ and .NET 8+. EF Core 10 runs on .NET 10, the LTS release supported until November 2028, while .NET 8 and .NET 9 both leave support on November 10, 2026. EF Core 11 arrives with .NET 11 in November 2026.
How SQL Server Executes a Query from .NET#
A five-step mental model explains most problems in this guide:
- Submission. Parameterized commands from SqlClient, EF Core and Dapper arrive as
sp_executesqlcalls carrying the SQL text and a parameter declaration such as@customerId int. - Compilation. The optimizer uses statistics to estimate row counts, and those estimates drive every choice: seek or scan, join type and memory grant.
- Caching. The plan is cached by exact text, parameter declarations and SET options, then reused whatever the parameter values are.
- Execution. Operators read 8 KB pages. Logical reads measure the work far more reliably than elapsed time.
- Recording. Query Store, on by default since SQL Server 2022 and in Azure SQL, keeps plans and runtime statistics per query.
Stale statistics break step 2, parameter sniffing lives in step 3, and indexes pay off in step 4.
Getting Started with Microsoft.Data.SqlClient#
A minimal, production-shaped query uses async I/O, a cancellation token and typed parameters:
using System.Data;
using Microsoft.Data.SqlClient;
// Read the connection string from configuration or the environment, never from source code
var connectionString = Environment.GetEnvironmentVariable("SHOP_DB")
?? throw new InvalidOperationException("Set SHOP_DB to a SQL Server connection string.");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cts.Token);
await using var command = new SqlCommand(
"""
SELECT TOP (20) OrderId, OrderDate, Total
FROM dbo.Orders
WHERE CustomerId = @customerId
ORDER BY OrderDate DESC;
""",
connection);
// Explicit type: the server sees "@customerId int" on every call
command.Parameters.Add("@customerId", SqlDbType.Int).Value = 42;
await using var reader = await command.ExecuteReaderAsync(cts.Token);
while (await reader.ReadAsync(cts.Token))
{
var orderId = reader.GetInt64(0);
var orderDate = reader.GetDateTime(1);
var total = reader.GetDecimal(2);
Console.WriteLine($"{orderId} {orderDate:yyyy-MM-dd} {total,12:N2}");
}Pooling is on by default, so OpenAsync usually reuses a physical connection and disposing returns it. Open late, dispose early, and never store a connection in a singleton. Encryption is mandatory by default, so TrustServerCertificate=True belongs only in local development settings. The Dapper and ADO.NET guide covers the mapping layer.
Indexing Strategy: Clustered, Nonclustered, Covering and Filtered Indexes#
Design indexes from the queries you run most, not from the table definition.
Choose the Clustered Index Deliberately#
The clustered index is the table: its leaf level holds complete rows sorted by the key, and every nonclustered index stores that key as its row locator. Keep it narrow, unique, stable and ever-increasing, which is why int or bigint identity keys remain the OLTP default.
GUID keys need care. Guid.NewGuid() scatters inserts and causes page splits. Guid.CreateVersion7(), added in .NET 9, does not fix this in SQL Server, because the engine treats the last six bytes of a uniqueidentifier as most significant while version 7 puts its timestamp first. Use NEWSEQUENTIALID() or the EF Core SQL Server provider, which generates sequential GUIDs suited to that sort order by default.
Nonclustered and Covering Indexes#
A nonclustered seek is fast, but every column the index lacks costs a key lookup per row. Past a few thousand rows, the optimizer prefers a scan and ignores your index. A covering index fixes this: key columns serve the seek and sort, while INCLUDE columns ride along in the leaf level. Put equality columns first and the range or ORDER BY column next, so (CustomerId, OrderDate) answers "newest orders for customer 42" without a sort. Every index also costs writes, logging and locking, so treat missing-index suggestions as hints, not designs.
Filtered Indexes for Hot Subsets#
A filtered index covers only rows matching a simple predicate, such as a work queue (WHERE Status = 0) or live rows in a soft-delete table. It is small and selective, but filters allow only simple comparisons. If the query compares the filtered column with a parameter, the cached plan must serve every value, so the optimizer usually skips the index; keep the filter value as a literal.
CREATE TABLE dbo.Orders
(
OrderId bigint IDENTITY(1,1) NOT NULL,
CustomerId int NOT NULL,
Status tinyint NOT NULL, -- 0 = Pending, 1 = Paid, 2 = Shipped
OrderDate datetime2(3) NOT NULL,
Total decimal(19,4) NOT NULL,
Notes nvarchar(max) NULL,
CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId)
);
-- Covering index: seek on CustomerId, rows already ordered by date,
-- Total stored in the leaf level so no key lookup is needed
CREATE NONCLUSTERED INDEX IX_Orders_CustomerId_OrderDate
ON dbo.Orders (CustomerId, OrderDate DESC)
INCLUDE (Total);
-- Filtered index: only the small set of pending orders a background job polls
CREATE NONCLUSTERED INDEX IX_Orders_Pending
ON dbo.Orders (OrderDate)
INCLUDE (CustomerId, Total)
WHERE Status = 0;| Index type | Best for | Watch out for |
|---|---|---|
| Clustered | Physical row order; range scans on the key | Wide or random keys bloat every other index |
| Nonclustered | Selective lookups on non-key columns | Key lookups for columns the index lacks |
Covering (INCLUDE) | Hot queries with a known column list | Storage and write cost; skip wide text columns |
Filtered (WHERE) | Small, hot subsets such as pending work | Parameterized predicates may not match |
| Columnstore | Aggregations over millions of rows | Singleton lookups and frequent small updates |
| JSON index | Path lookups in json columns (SQL Server 2025, Azure SQL) | Requires a clustered primary key |
How to Read a SQL Server Execution Plan#
The estimated plan shows what the optimizer intends; the actual plan (Include Actual Execution Plan in SSMS, or SET STATISTICS XML ON) adds real row counts and runtime warnings. Read it right to left, following the data toward the root, and check:
- Estimated versus actual rows. A tenfold gap means bad information: stale statistics, sniffing, or a non-sargable predicate such as a function around a column.
- Seeks versus scans. Scanning millions of rows to return twenty signals a missing or unusable index.
- Key Lookup under Nested Loops. The index almost covers the query;
INCLUDEthe missing columns if it is hot. - Sort and Hash Match warnings. Spills to tempdb and excessive memory grants usually trace back to bad estimates.
CONVERT_IMPLICITon a column. A parameter type mismatch, often caused by client code.
Use SET STATISTICS IO for repeatable logical-read measurements, and Query Store for production evidence:
SET STATISTICS IO, TIME ON;
SELECT TOP (20) OrderId, OrderDate, Total
FROM dbo.Orders
WHERE CustomerId = 42
ORDER BY OrderDate DESC;
SET STATISTICS IO, TIME OFF;
-- The ten most CPU-hungry queries in the Query Store retention window
SELECT TOP (10)
q.query_id,
qt.query_sql_text,
SUM(rs.count_executions) AS executions,
SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0 AS total_cpu_ms,
MAX(rs.max_logical_io_reads) AS max_logical_reads,
COUNT(DISTINCT p.plan_id) AS plan_count
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY total_cpu_ms DESC;A hot query with several plans deserves a closer look. With EF Core, TagWith("Orders.GetRecent") adds a comment to the generated SQL so you can find it in traces, and ToQueryString() shows the SQL during development. The EF Core performance guide covers the ORM side.
Parameter Sniffing: Why the Same Query Is Fast and Slow#
The optimizer builds a plan for the parameter values of the first call, then reuses it for every value. With skewed data, such as one customer owning millions of orders while most have forty, whichever call compiles first decides everyone's plan. Symptoms include sudden slowdowns without a deployment, and queries that are slow in the app but fast in SSMS, which uses different SET options such as ARITHABORT and therefore caches its own plan.
Parameter Sensitive Plan (PSP) optimization, added in SQL Server 2022 at compatibility level 160, caches several plan variants for equality predicates on skewed columns. SQL Server 2025 at level 170 extends PSP to DML and adds Optional Parameter Plan Optimization (OPPO) for the catch-all pattern (Column = @p OR @p IS NULL). When these do not apply:
OPTION (RECOMPILE)suits expensive, infrequent statements like reports and searches.OPTIMIZE FOR UNKNOWNtrades peak speed for a predictable plan.- Query Store hints apply fixes by query ID without redeploying.
- A better covering index often makes both small and large cases cheap, which ends the problem.
Client code can make it worse, because AddWithValue infers both type and length from a .NET string:
// Avoid: sends nvarchar(7) for "ABC-123" and nvarchar(9) for "ABC-12345".
// Each declaration gets its own cache entry, and nvarchar compared with a
// varchar column adds CONVERT_IMPLICIT, which can prevent an index seek.
command.Parameters.AddWithValue("@sku", sku);
// Prefer: match the column's type and declared length exactly
command.Parameters.Add("@sku", SqlDbType.VarChar, 32).Value = sku;
command.Parameters.Add("@since", SqlDbType.DateTime2).Value = since;
command.Parameters.Add("@status", SqlDbType.TinyInt).Value = (byte)OrderStatus.Pending;EF Core sends correct types when the model declares them, for example [Unicode(false)] with [MaxLength(32)], and Dapper offers DbString with IsAnsi and Length. On the server, these are the usual fixes:
-- Recompile a statement whose best plan depends heavily on the value
SELECT OrderId, OrderDate, Total
FROM dbo.Orders
WHERE CustomerId = @customerId
OPTION (RECOMPILE);
-- Attach the same hint through Query Store, without touching application code
EXEC sys.sp_query_store_set_hints
@query_id = 4711,
@query_hints = N'OPTION(RECOMPILE)';
-- Remove it once an index or a code change has fixed the root cause
EXEC sys.sp_query_store_clear_hints @query_id = 4711;Statistics: The Optimizer's Map of Your Data#
Statistics are histograms of up to 200 steps, created and updated automatically by default. From compatibility level 130, auto-update fires after about MIN(500 + 0.20 * n, SQRT(1000 * n)) modifications: roughly 44,700 changes for 2 million rows and 316,000 for 100 million. That is still too lazy for ascending keys, where today's rows fall beyond the last histogram step, and for bulk loads, after which you should update statistics explicitly. Use FULLSCAN where sampling misses skew. AUTO_UPDATE_STATISTICS_ASYNC, off by default, avoids compile stalls on huge tables at the cost of one more stale execution.
-- How stale are the statistics on a hot table?
SELECT s.name AS stats_name,
sp.last_updated,
sp.rows,
sp.rows_sampled,
sp.modification_counter
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID(N'dbo.Orders')
ORDER BY sp.modification_counter DESC;
-- After a large import, refresh the statistics your critical queries depend on
UPDATE STATISTICS dbo.Orders IX_Orders_CustomerId_OrderDate WITH FULLSCAN;Transactions and Isolation Levels: RCSI, SNAPSHOT and Optimized Locking#
Isolation decides which anomalies a transaction can see and, in practice, who waits for whom.
| Isolation level | Dirty reads | Non-repeatable reads | Phantoms | Readers block writers | Typical use |
|---|---|---|---|---|---|
READ UNCOMMITTED (NOLOCK) | Yes | Yes | Yes | No | Almost never |
| READ COMMITTED (locking) | No | Yes | Yes | Yes | SQL Server default |
| READ COMMITTED with RCSI | No | Yes | Yes | No | Azure SQL default; best OLTP choice |
| REPEATABLE READ | No | No | Yes | Yes | Rare |
| SNAPSHOT | No | No | No | No | Consistent reports and exports |
| SERIALIZABLE | No | No | No | Yes | Narrow critical sections |
With locking READ COMMITTED, readers wait for writers' exclusive locks. READ_COMMITTED_SNAPSHOT (RCSI) reads the last committed row versions as of each statement, so readers and writers stop blocking each other. It is the default in Azure SQL Database and usually the most effective blocking fix on SQL Server. Versions live in tempdb, or in the database's persistent version store when accelerated database recovery (ADR) is on.
SNAPSHOT isolation gives the whole transaction one consistent view. You allow it per database and request it per transaction, and if a snapshot transaction updates a row changed after it started, SQL Server raises error 3960 and rolls back. Neither level protects "check, then insert" logic, so enforce invariants with unique constraints or WITH (UPDLOCK, HOLDLOCK) on the check.
Watch two .NET traps. new TransactionScope() defaults to SERIALIZABLE, a frequent deadlock source, and needs TransactionScopeAsyncFlowOption.Enabled to flow across await. And never await HTTP calls or brokers while holding locks.
SQL Server 2025 adds optimized locking, already on in Azure SQL Database. Transaction ID locking holds one lock per transaction instead of thousands of row locks, and lock after qualification evaluates predicates against committed versions before locking. It requires ADR and works best with RCSI.
-- Statement-level row versioning for the default READ COMMITTED level
ALTER DATABASE Shop SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
-- Allow transactions to request transaction-level SNAPSHOT isolation
ALTER DATABASE Shop SET ALLOW_SNAPSHOT_ISOLATION ON;
-- SQL Server 2025: optimized locking depends on accelerated database recovery
ALTER DATABASE Shop SET ACCELERATED_DATABASE_RECOVERY = ON;
ALTER DATABASE Shop SET OPTIMIZED_LOCKING = ON;Deadlocks: Detection, Diagnosis and Retry#
The lock monitor looks for lock cycles every five seconds, or as often as every 100 milliseconds when deadlocks are frequent. It picks the victim by DEADLOCK_PRIORITY, then by the cheapest rollback, and the victim receives error 1205, a SqlException with Number 1205 in .NET. Typical causes are code paths that update the same tables in opposite order, readers doing key lookups against writers, serializable read-then-insert sequences, and unindexed foreign keys that make deletes scan child tables.
Capture evidence first. SQL Server's system_health session records every xml_deadlock_report; on Azure SQL Database, create an Extended Events session for database_xml_deadlock_report.
-- SQL Server: recent deadlock graphs from the system_health session
SELECT
xed.value('@timestamp', 'datetime2(3)') AS event_time_utc,
xed.query('(data/value/deadlock)[1]') AS deadlock_graph
FROM (
SELECT CAST(event_data AS xml) AS event_xml
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL)
WHERE object_name = N'xml_deadlock_report'
) AS e
CROSS APPLY e.event_xml.nodes('/event') AS x(xed)
ORDER BY event_time_utc DESC;Fix the cause with consistent ordering, shorter transactions, supporting indexes or RCSI. Then retry what remains, always the whole transaction and never a single statement:
using System.Data;
using Microsoft.Data.SqlClient;
using Polly;
using Polly.Retry;
public sealed class TransferService(string connectionString)
{
private static readonly ResiliencePipeline Retry = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
// 1205: deadlock victim; 3960: update conflict under SNAPSHOT isolation
ShouldHandle = new PredicateBuilder()
.Handle<SqlException>(ex => ex.Number is 1205 or 3960),
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(100),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
})
.Build();
public Task TransferAsync(int fromId, int toId, decimal amount, CancellationToken ct) =>
Retry.ExecuteAsync(async token =>
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(token);
await using var tx = connection.BeginTransaction(IsolationLevel.ReadCommitted);
// Touch accounts in ascending ID order so opposite transfers cannot deadlock
var steps = fromId < toId
? new[] { (fromId, -amount), (toId, amount) }
: new[] { (toId, amount), (fromId, -amount) };
foreach (var (accountId, delta) in steps)
{
await UpdateBalanceAsync(connection, tx, accountId, delta, token);
}
await tx.CommitAsync(token);
}, ct).AsTask();
private static async Task UpdateBalanceAsync(
SqlConnection connection, SqlTransaction tx, int accountId, decimal delta,
CancellationToken ct)
{
await using var command = new SqlCommand(
"UPDATE dbo.Accounts SET Balance += @delta WHERE AccountId = @id;",
connection, tx);
command.Parameters.Add(new SqlParameter("@delta", SqlDbType.Decimal)
{
Precision = 19,
Scale = 4,
Value = delta
});
command.Parameters.Add("@id", SqlDbType.Int).Value = accountId;
await command.ExecuteNonQueryAsync(ct);
}
}Each attempt opens a fresh connection and transaction, so the unit of work must be safe to repeat. The resilience guide shows how to combine retries with timeouts and circuit breakers.
Connection Pooling with Microsoft.Data.SqlClient#
SqlClient keeps one pool per exact connection string and identity, including the SqlCredential or access token and any ambient transaction; even a different keyword order creates a new pool. Defaults are Max Pool Size=100, Min Pool Size=0 and a 15-second Connect Timeout, and idle connections above the minimum are usually closed after four to eight minutes. When the pool is empty, Open waits and fails once the timeout elapses.
Exhaustion rarely calls for a bigger pool. Leaked connections, transactions held across slow work and sync-over-async code are the usual culprits. Fragmentation is the other trap, because per-tenant connection strings or constantly refreshed tokens each create their own pool.
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// SqlClient 7.1 ships a rewritten pool behind a switch; evaluate it in load tests first
// AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", true);
var csb = new SqlConnectionStringBuilder(builder.Configuration.GetConnectionString("Shop"))
{
ApplicationName = "Shop.Api", // visible in sys.dm_exec_sessions
MaxPoolSize = 100, // the default; change it only after load testing
MinPoolSize = 5 // keep a few warm connections for bursty traffic
};
// Raw SqlClient and Dapper: one factory, one connection string, one pool
builder.Services.AddSingleton(new SqlConnectionFactory(csb.ConnectionString));
// EF Core 9+: UseAzureSql configures Azure-appropriate connection resiliency
builder.Services.AddDbContext<ShopDbContext>(options =>
options.UseAzureSql(csb.ConnectionString));
var app = builder.Build();
app.Run();
public sealed class SqlConnectionFactory(string connectionString)
{
// Retries opening only; retry transactions explicitly as shown earlier
private static readonly SqlRetryLogicBaseProvider OpenRetry =
SqlConfigurableRetryFactory.CreateExponentialRetryProvider(new SqlRetryLogicOption
{
NumberOfTries = 4,
DeltaTime = TimeSpan.FromSeconds(5),
MaxTimeInterval = TimeSpan.FromSeconds(60)
});
public async Task<SqlConnection> OpenAsync(CancellationToken ct)
{
var connection = new SqlConnection(connectionString) { RetryLogicProvider = OpenRetry };
await connection.OpenAsync(ct);
return connection;
}
}On-premises, use UseSqlServer with EnableRetryOnFailure(), and UseCompatibilityLevel(170) to target SQL Server 2025 features. To watch the pool, run dotnet-counters against the Microsoft.Data.SqlClient.EventSource provider and track counters such as active-hard-connections and number-of-free-connections.
SQL Server 2025 Features for .NET Developers: Vectors, JSON and More#
Vectors. The vector type is generally available: float32 by default, up to 1,998 dimensions, with float16 in preview. VECTOR_DISTANCE supports cosine, Euclidean and dot-product metrics, and Microsoft suggests exact search for up to roughly 50,000 vectors, more when ordinary filters narrow the candidates first. DiskANN vector indexes and VECTOR_SEARCH remain in preview behind the PREVIEW_FEATURES setting; they need a clustered integer primary key and at least 100 vectors, and indexed tables are no longer read-only.
JSON. The native json type is generally available, stored as binary UTF-8 up to 2 GB, with in-place updates through modify. JSON_OBJECTAGG and JSON_ARRAYAGG aggregate rows into JSON, and CREATE JSON INDEX speeds up JSON_VALUE, JSON_PATH_EXISTS and JSON_CONTAINS.
More. REGEXP_LIKE and related regular expression functions, optimized sp_executesql against compilation storms, and AI_GENERATE_EMBEDDINGS with CREATE EXTERNAL MODEL for embeddings in T-SQL.
CREATE TABLE dbo.Products
(
ProductId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Products PRIMARY KEY CLUSTERED,
Name nvarchar(200) NOT NULL,
Attributes json NULL, -- native binary JSON
Embedding vector(1536) NULL -- float32 by default
);
CREATE JSON INDEX IX_Products_Attributes ON dbo.Products (Attributes);
-- In-place JSON update without rewriting the whole document
UPDATE dbo.Products
SET Attributes.modify('$.stock', 12)
WHERE ProductId = 7;
-- Exact k-NN search, filtered by a JSON attribute first
SELECT TOP (10) ProductId, Name,
VECTOR_DISTANCE('cosine', Embedding, @query) AS distance
FROM dbo.Products
WHERE JSON_VALUE(Attributes, '$.category') = N'laptops'
ORDER BY distance;Send vectors as SqlVector<float> (SqlClient 6.1+) rather than JSON text; Microsoft's release notes report about 50 times faster reads, 3.3 times faster writes and 19 times faster bulk copy in their tests. EF Core 10 maps SqlVector<float> properties, translates EF.Functions.VectorDistance, and stores ToJson() complex types and primitive collections in json columns with UseAzureSql or compatibility level 170.
using System.Data;
using Microsoft.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Data.SqlTypes;
using Microsoft.EntityFrameworkCore;
public sealed record ProductMatch(int ProductId, string Name, double Distance);
public static class ProductSearch
{
// ADO.NET: binary vector parameter, no JSON round trip
public static async Task<List<ProductMatch>> SearchAsync(
SqlConnection connection, ReadOnlyMemory<float> embedding, CancellationToken ct)
{
await using var command = new SqlCommand(
"""
SELECT TOP (10) ProductId, Name,
VECTOR_DISTANCE('cosine', Embedding, @query) AS distance
FROM dbo.Products
ORDER BY distance;
""",
connection);
command.Parameters.Add(new SqlParameter("@query", SqlDbTypeExtensions.Vector)
{
Value = new SqlVector<float>(embedding)
});
var matches = new List<ProductMatch>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
matches.Add(new(reader.GetInt32(0), reader.GetString(1), reader.GetDouble(2)));
}
return matches;
}
// EF Core 10: Product.Embedding is mapped with [Column(TypeName = "vector(1536)")]
public static Task<List<string>> SearchNamesAsync(
ShopDbContext db, ReadOnlyMemory<float> embedding, CancellationToken ct)
{
var query = new SqlVector<float>(embedding);
return db.Products
.OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding, query))
.Take(10)
.Select(p => p.Name)
.ToListAsync(ct);
}
}EF Core 11 adds HasVectorIndex, an approximate VectorSearch operator and full-text table-valued functions for hybrid search. See embeddings and vector databases in .NET for the retrieval side.
Azure SQL Considerations for .NET Applications#
- Transient faults are normal. Failovers and throttling raise errors such as 40613, 40197 and 40501. Microsoft recommends waiting about five seconds before the first retry and backing off exponentially up to 60 seconds.
- Retry units of work. Wrap EF Core transactions in
CreateExecutionStrategy().ExecuteAsync, and make writes idempotent, because a drop during commit leaves the outcome unknown. - Prefer the Redirect policy. Inside Azure, clients connect directly to the database node on ports 11000 to 11999 after the gateway handshake; the Proxy policy, the default from outside Azure, adds latency.
- Plan for serverless resume. General Purpose serverless databases can auto-pause after at least 15 idle minutes, and connections fail briefly while they resume.
- Go passwordless. Use a managed identity with
Authentication=Active Directory Default, and on SqlClient 7.0+ addMicrosoft.Data.SqlClient.Extensions.Azure. - Offload reads.
ApplicationIntent=ReadOnlyroutes reporting queries to readable replicas where the tier provides them.
Best Practices#
- Index from Query Store data. Cover the hottest queries and drop indexes nothing reads.
- Type every parameter. Match SQL types and lengths to avoid conversions and plan cache bloat.
- Enable RCSI. It removes reader-writer blocking and matches Azure SQL behavior.
- Keep transactions short and ordered. Access rows in a consistent order and never await external calls inside them.
- Retry whole transactions. Handle 1205, 3960 and Azure transient errors with backoff and idempotent work.
- Read driver release notes. SqlClient 7.0 moved Entra ID authentication into a separate package.
Common Pitfalls#
- Using
NOLOCKagainst blocking. It can skip or duplicate rows; use RCSI. - Trusting SSMS timings. Different SET options mean a different cached plan.
- Accepting every missing-index hint. Overlapping indexes slow every write.
- Default
TransactionScope. SERIALIZABLE isolation invites deadlocks. - Functions on indexed columns.
WHERE YEAR(OrderDate) = 2026cannot seek; use a date range. - Connection strings per tenant. Pool fragmentation multiplies physical connections.
- Embeddings as JSON strings. Use
vectorandSqlVector<float>instead.
SQL Server 2025 vs Azure SQL Database: Defaults That Affect Your Code#
| Behavior | SQL Server 2025 (new database) | Azure SQL Database |
|---|---|---|
| READ_COMMITTED_SNAPSHOT | Off until enabled | On by default |
| Optimized locking | Off; requires ADR | On |
| Default compatibility level | 170 | 170 for new databases |
| Query Store | On | On |
Vector index and VECTOR_SEARCH | Preview behind PREVIEW_FEATURES | Preview |
| Transient connection errors | Rare, mostly failovers | Expected; retries mandatory |
| Connection path | Direct to the instance | Gateway, then Redirect or Proxy |
Frequently Asked Questions#
Should I enable READ_COMMITTED_SNAPSHOT on SQL Server?#
For most OLTP applications, yes. RCSI removes reader-writer blocking without code changes and matches Azure SQL Database, so development and production behave alike. Review check-then-insert logic and monitor version store space during long transactions.
Is WITH (NOLOCK) a safe way to fix blocking?#
No. NOLOCK reads uncommitted data and can skip rows or read them twice during page splits, so results can be wrong, not just stale. RCSI provides non-blocking reads of committed data.
Do I still need to handle parameter sniffing on SQL Server 2022 or 2025?#
Yes, less often. PSP covers equality predicates on skewed columns and OPPO covers optional parameters, both only at newer compatibility levels. Watch Query Store for hot queries with several plans and fix them with indexes, OPTION (RECOMPILE) or Query Store hints.
Can SQL Server 2025 replace a dedicated vector database?#
For many .NET RAG workloads, yes. Vectors sit next to relational data, filters are ordinary WHERE clauses, and exact VECTOR_DISTANCE search is generally available. Approximate indexes are still in preview, so huge corpora with strict latency targets may still justify a specialized store.
How large should the SqlClient connection pool be?#
Start with the default of 100 and measure under load. Exhaustion usually means leaked connections or long transactions, and a bigger pool only moves the queue into SQL Server.
Summary#
- Index from the workload, and read actual plans for gaps between estimated and actual rows.
- Handle parameter sniffing with typed parameters, indexes, PSP, OPPO and Query Store hints.
- Enable RCSI, keep transactions short and ordered, and retry whole transactions.
- Dispose connections promptly and avoid fragmented connection strings.
- Adopt
vectorandjsonthroughSqlVector<float>and EF Core 10, and plan for Azure SQL's defaults.