| name | dotnet-sqlserver-best-practices |
| description | SQL Server database design best practices, naming conventions, indexing strategies, and performance optimization for .NET applications using Microsoft.Data.SqlClient and EF Core. |
| version | 1.0.0 |
| language | SQL/C# |
| framework | SQL Server 2019+, .NET 8+ |
| dependencies | Microsoft.EntityFrameworkCore.SqlServer |
SQL Server Best Practices for .NET
Overview
Best practices for SQL Server database design, naming conventions, indexing, and performance optimization when using with .NET and Entity Framework Core.
Quick Reference
| Category | Best Practice |
|---|
| Naming | PascalCase for tables/columns |
| Primary Keys | Use uniqueidentifier (Guid) with NEWSEQUENTIALID() or int/bigint IDENTITY |
| Timestamps | Use datetimeoffset with UTC |
| Indexes | Index foreign keys, unique constraints |
| Strings | Use nvarchar(n) with explicit lengths |
| JSON | Use nvarchar(max) with JSON functions (SQL Server 2016+) |
Naming Conventions
PascalCase Standard
SQL Server convention is PascalCase for all identifiers:
CREATE TABLE UserProfiles (
UserId uniqueidentifier PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
FirstName nvarchar(100) NOT NULL,
LastName nvarchar(100) NOT NULL,
CreatedAt datetimeoffset NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt datetimeoffset NOT NULL DEFAULT SYSUTCDATETIME()
);
EF Core Setup
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString);
});
Naming Patterns
| Object | Pattern | Example |
|---|
| Tables | PascalCase (plural) | UserProfiles, OrderItems |
| Columns | PascalCase | FirstName, CreatedAt |
| Primary Keys | PK_{Table} | PK_Users, PK_Orders |
| Foreign Keys | FK_{Table}_{RefTable} | FK_Orders_Users |
| Indexes | IX_{Table}_{Column(s)} | IX_Users_Email |
| Unique Indexes | UIX_{Table}_{Column(s)} | UIX_Users_Email |
| Check Constraints | CK_{Table}_{Column} | CK_Users_Age |
| Default Constraints | DF_{Table}_{Column} | DF_Users_CreatedAt |
| Schemas | PascalCase | dbo, Sales, Identity |
Data Types
Recommended Types
| C# Type | SQL Server Type | Notes |
|---|
Guid | uniqueidentifier | Use NEWSEQUENTIALID() to avoid index fragmentation |
string | nvarchar(n) | Always specify length; Unicode by default |
string (large) | nvarchar(max) | Only when > 4000 characters needed |
int | int | 4 bytes, -2B to +2B |
long | bigint | 8 bytes |
decimal | decimal(p,s) | Exact precision for money |
double | float | Floating point |
bool | bit | 0/1 |
DateTime | datetimeoffset | Always use with time zone |
DateTime (date only) | date | When time is not needed |
byte[] | varbinary(max) | Binary data |
byte[] (concurrency) | rowversion | Auto-increment on update |
nvarchar vs varchar
CREATE TABLE Products (
Id uniqueidentifier PRIMARY KEY,
Name nvarchar(200) NOT NULL,
Description nvarchar(2000)
);
CREATE TABLE AuditLogs (
Id bigint IDENTITY PRIMARY KEY,
Action varchar(50) NOT NULL
);
Always specify length:
nvarchar(max) can't be indexed and has performance implications
- Use the smallest length that fits the domain (email: 256, name: 100, etc.)
Timestamps
CreatedAt datetimeoffset NOT NULL CONSTRAINT DF_Users_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt datetimeoffset NOT NULL CONSTRAINT DF_Users_UpdatedAt DEFAULT SYSUTCDATETIME()
CreatedAt datetime2(7) NOT NULL DEFAULT SYSUTCDATETIME()
builder.Property(e => e.CreatedAt)
.HasColumnType("datetimeoffset")
.IsRequired()
.HasDefaultValueSql("SYSUTCDATETIME()");
JSON Support (SQL Server 2016+)
CREATE TABLE Products (
Id uniqueidentifier PRIMARY KEY,
Metadata nvarchar(max) NULL,
CONSTRAINT CK_Products_Metadata_JSON CHECK (ISJSON(Metadata) = 1)
);
SELECT Id, JSON_VALUE(Metadata, '$.category') AS Category
FROM Products
WHERE JSON_VALUE(Metadata, '$.isActive') = 'true';
builder.Property(e => e.Metadata)
.HasColumnType("nvarchar(max)");
builder.OwnsOne(e => e.Settings, settingsBuilder =>
{
settingsBuilder.ToJson();
});
Primary Keys
Sequential GUID - Recommended
CREATE TABLE Users (
Id uniqueidentifier PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
Email nvarchar(256) NOT NULL
);
builder.Property(e => e.Id)
.HasDefaultValueSql("NEWSEQUENTIALID()");
public static User Create(...)
{
return new User(Guid.NewGuid(), ...);
}
Why NEWSEQUENTIALID() over NEWID()?
NEWID() generates random GUIDs causing clustered index fragmentation
NEWSEQUENTIALID() generates sequential GUIDs for efficient inserts
- 16 bytes vs 4 bytes (int) — tradeoff for global uniqueness
IDENTITY Alternative
CREATE TABLE Orders (
Id int IDENTITY(1,1) PRIMARY KEY,
OrderNumber nvarchar(20) NOT NULL
);
CREATE TABLE AuditLogs (
Id bigint IDENTITY(1,1) PRIMARY KEY
);
builder.Property(e => e.Id)
.UseIdentityColumn();
Indexing Strategies
Index Foreign Keys
CREATE NONCLUSTERED INDEX IX_Orders_UserId ON Orders(UserId);
CREATE NONCLUSTERED INDEX IX_OrderItems_OrderId ON OrderItems(OrderId);
builder.HasIndex(o => o.UserId);
Unique Indexes
CREATE UNIQUE NONCLUSTERED INDEX UIX_Users_Email ON Users(Email);
builder.HasIndex(u => u.Email).IsUnique();
Composite Indexes
CREATE NONCLUSTERED INDEX IX_Orders_UserId_Status ON Orders(UserId, Status);
CREATE NONCLUSTERED INDEX IX_Orders_CreatedAt_Status ON Orders(CreatedAt DESC, Status);
Order matters! Index on (UserId, Status) helps:
WHERE UserId = @id AND Status = @status - uses index
WHERE UserId = @id - uses index
WHERE Status = @status - does NOT use index
Filtered Indexes
CREATE NONCLUSTERED INDEX IX_Orders_Pending
ON Orders(CreatedAt)
WHERE Status = 'Pending';
CREATE NONCLUSTERED INDEX IX_Users_Active_Email
ON Users(Email)
WHERE IsDeleted = 0;
builder.HasIndex(o => o.CreatedAt)
.HasFilter("[Status] = 'Pending'");
Covering Indexes (INCLUDE)
CREATE NONCLUSTERED INDEX IX_Users_Email_Include
ON Users(Email)
INCLUDE (FirstName, LastName);
builder.HasIndex(u => u.Email)
.IncludeProperties(u => new { u.FirstName, u.LastName });
Full-Text Search Indexes
CREATE FULLTEXT CATALOG ProductSearch AS DEFAULT;
CREATE FULLTEXT INDEX ON Products(Name, Description)
KEY INDEX PK_Products
WITH CHANGE_TRACKING AUTO;
SELECT * FROM Products
WHERE CONTAINS((Name, Description), '"search term" OR FORMSOF(INFLECTIONAL, "search")');
SELECT * FROM Products
WHERE FREETEXT((Name, Description), 'search term');
Columnstore Indexes (Analytics)
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Analytics
ON Orders(CreatedAt, Status, TotalAmount, UserId);
SELECT Status, COUNT(*) AS OrderCount, SUM(TotalAmount) AS Total
FROM Orders
GROUP BY Status;
When NOT to Index
Do not index:
- Very small tables (< 1000 rows)
- Columns rarely used in WHERE/JOIN/ORDER BY
- Columns with low cardinality (few distinct values like bit columns)
- Columns that change frequently (high write overhead)
- Wide columns (nvarchar(max), varbinary(max))
Constraints
Primary Key Constraints
CONSTRAINT PK_Users PRIMARY KEY CLUSTERED (Id)
builder.HasKey(u => u.Id);
Foreign Key Constraints
CONSTRAINT FK_Orders_Users
FOREIGN KEY (UserId)
REFERENCES Users(Id)
ON DELETE NO ACTION,
CONSTRAINT FK_OrderItems_Orders
FOREIGN KEY (OrderId)
REFERENCES Orders(Id)
ON DELETE CASCADE
builder.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserId)
.OnDelete(DeleteBehavior.Restrict);
Check Constraints
CONSTRAINT CK_Users_Age CHECK (Age >= 18 AND Age <= 120),
CONSTRAINT CK_Products_Price CHECK (Price >= 0),
CONSTRAINT CK_Orders_Quantity CHECK (Quantity > 0)
builder.ToTable(t => t.HasCheckConstraint(
"CK_Products_Price",
"[Price] >= 0"));
Unique Constraints
CONSTRAINT UQ_UserProfiles_UserType UNIQUE (UserId, ProfileType)
builder.HasIndex(p => new { p.UserId, p.ProfileType }).IsUnique();
Performance Optimization
Connection Pooling
"Server=localhost;Database=MyDb;User Id=app_user;Password=pass;Encrypt=True;TrustServerCertificate=True;Min Pool Size=5;Max Pool Size=100"
Batch Operations
context.Users.AddRange(users);
await context.SaveChangesAsync();
foreach (var user in users)
{
context.Users.Add(user);
await context.SaveChangesAsync();
}
AsNoTracking for Read-Only Queries
var users = await context.Users
.AsNoTracking()
.ToListAsync();
Compiled Queries
private static readonly Func<ApplicationDbContext, string, Task<User?>> GetUserByEmail =
EF.CompileAsyncQuery((ApplicationDbContext context, string email) =>
context.Users.FirstOrDefault(u => u.Email == email));
var user = await GetUserByEmail(context, email);
Pagination
SELECT * FROM Users
ORDER BY CreatedAt DESC
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
var users = await context.Users
.OrderByDescending(u => u.CreatedAt)
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToListAsync();
Avoid N+1 Queries
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
var user = await context.Users.FindAsync(order.UserId);
}
var orders = await context.Orders
.Include(o => o.User)
.ToListAsync();
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
Query Hints
var reports = await context.Orders
.TagWith("OPTION (MAXDOP 4)")
.AsNoTracking()
.ToListAsync();
var orders = await context.Orders
.FromSqlRaw("SELECT * FROM Orders WITH (NOLOCK) WHERE Status = {0}", status)
.ToListAsync();
Maintenance
Update Statistics
UPDATE STATISTICS Users;
EXEC sp_updatestats;
UPDATE STATISTICS Users WITH FULLSCAN;
Index Maintenance
SELECT
OBJECT_NAME(ips.object_id) AS TableName,
i.name AS IndexName,
ips.avg_fragmentation_in_percent,
ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
INNER JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 10
AND ips.page_count > 1000
ORDER BY ips.avg_fragmentation_in_percent DESC;
ALTER INDEX IX_Orders_UserId ON Orders REORGANIZE;
ALTER INDEX IX_Orders_UserId ON Orders REBUILD WITH (ONLINE = ON);
ALTER INDEX ALL ON Orders REBUILD WITH (ONLINE = ON);
Monitor Index Usage
SELECT
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
ius.user_seeks,
ius.user_scans,
ius.user_lookups,
ius.user_updates
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats ius
ON i.object_id = ius.object_id AND i.index_id = ius.index_id
WHERE OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
AND i.type_desc = 'NONCLUSTERED'
AND ISNULL(ius.user_seeks, 0) + ISNULL(ius.user_scans, 0) + ISNULL(ius.user_lookups, 0) = 0
ORDER BY ius.user_updates DESC;
SELECT TOP 20
OBJECT_NAME(mid.object_id) AS TableName,
migs.avg_user_impact,
migs.user_seeks,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns
FROM sys.dm_db_missing_index_groups mig
INNER JOIN sys.dm_db_missing_index_group_stats migs ON mig.index_group_handle = migs.group_handle
INNER JOIN sys.dm_db_missing_index_details mid ON mig.index_handle = mid.index_handle
ORDER BY migs.avg_user_impact * migs.user_seeks DESC;
Database Size
SELECT
t.name AS TableName,
s.row_count AS RowCount,
CAST(SUM(a.total_pages) * 8 / 1024.0 AS DECIMAL(10,2)) AS TotalSpaceMB,
CAST(SUM(a.used_pages) * 8 / 1024.0 AS DECIMAL(10,2)) AS UsedSpaceMB
FROM sys.tables t
INNER JOIN sys.indexes i ON t.object_id = i.object_id
INNER JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
INNER JOIN sys.allocation_units a ON p.partition_id = a.container_id
LEFT JOIN sys.dm_db_partition_stats s ON p.partition_id = s.partition_id AND p.index_id = s.index_id
GROUP BY t.name, s.row_count
ORDER BY SUM(a.total_pages) DESC;
Security Best Practices
Connection Security
"Server=prod-server;Database=MyDb;User Id=app_user;Password=pass;Encrypt=True;TrustServerCertificate=False"
"Server=localhost;Database=MyDb;Integrated Security=True;Encrypt=True"
"Server=myserver.database.windows.net;Database=MyDb;Authentication=Active Directory Managed Identity"
Least Privilege
CREATE LOGIN app_login WITH PASSWORD = 'SecurePassword123!';
CREATE USER app_user FOR LOGIN app_login;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO app_user;
CREATE USER migration_user FOR LOGIN migration_login;
ALTER ROLE db_ddladmin ADD MEMBER migration_user;
Parameterized Queries
var users = await context.Users
.Where(u => u.Email == email)
.ToListAsync();
var users = await connection.QueryAsync<User>(
"SELECT * FROM Users WHERE Email = @Email",
new { Email = email });
var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
SQL Server Features
Temporal Tables (System-Versioned)
CREATE TABLE Products (
Id uniqueidentifier PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
Name nvarchar(200) NOT NULL,
Price decimal(18,2) NOT NULL,
SysStartTime datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
SysEndTime datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ProductsHistory));
SELECT * FROM Products FOR SYSTEM_TIME AS OF '2024-01-15T10:00:00';
SELECT * FROM Products FOR SYSTEM_TIME BETWEEN '2024-01-01' AND '2024-02-01';
builder.ToTable("Products", b => b.IsTemporal());
var historicalProducts = await context.Products
.TemporalAsOf(DateTime.UtcNow.AddDays(-7))
.ToListAsync();
Sequences
CREATE SEQUENCE dbo.OrderNumberSequence
AS int
START WITH 1000
INCREMENT BY 1;
CREATE TABLE Orders (
Id uniqueidentifier PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
OrderNumber int NOT NULL DEFAULT NEXT VALUE FOR dbo.OrderNumberSequence
);
builder.Property(o => o.OrderNumber)
.HasDefaultValueSql("NEXT VALUE FOR dbo.OrderNumberSequence");
modelBuilder.HasSequence<int>("OrderNumberSequence")
.StartsAt(1000)
.IncrementsBy(1);
Computed Columns
ALTER TABLE Orders ADD TotalWithTax AS (TotalAmount * 1.21) PERSISTED;
ALTER TABLE Users ADD FullName AS (FirstName + ' ' + LastName);
builder.Property(o => o.TotalWithTax)
.HasComputedColumnSql("[TotalAmount] * 1.21", stored: true);
builder.Property(u => u.FullName)
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]");
High Concurrency Patterns
Optimistic Concurrency with rowversion
public class Order
{
public Guid Id { get; private set; }
public string Status { get; private set; }
public byte[] RowVersion { get; private set; }
}
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.RowVersion)
.IsRowVersion();
}
}
Handling Concurrency Conflicts
public async Task UpdateAsync(Order order, CancellationToken cancellationToken)
{
try
{
context.Orders.Update(order);
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
if (databaseValues is null)
{
throw new EntityNotFoundException("Order was deleted by another user");
}
throw new ConcurrencyException(
"Order was modified by another user. Please refresh and try again.");
}
}
Retry with Exponential Backoff (Polly)
using Polly;
using Polly.Retry;
using Microsoft.Data.SqlClient;
services.AddResiliencePipeline("database", builder =>
{
builder.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<DbUpdateConcurrencyException>()
.Handle<SqlException>(ex => ex.IsTransient()),
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = static args =>
{
Console.WriteLine($"Retry attempt {args.AttemptNumber} after {args.RetryDelay}");
return default;
}
});
});
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
});
});
DbContext Pooling for High Performance
services.AddDbContextPool<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString);
}, poolSize: 128);
services.AddPooledDbContextFactory<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString);
});
public class HighPerformanceQueryHandler
{
private readonly IDbContextFactory<ApplicationDbContext> _contextFactory;
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
return await context.Orders
.AsNoTracking()
.Where(o => o.Status == "Active")
.ToListAsync(ct);
}
}
Connection Pooling Configuration
var connectionString = new SqlConnectionStringBuilder
{
DataSource = "localhost",
InitialCatalog = "MyDb",
UserID = "app_user",
Password = "secret",
Pooling = true,
MinPoolSize = 10,
MaxPoolSize = 100,
ConnectTimeout = 30,
CommandTimeout = 60,
ConnectRetryCount = 3,
ConnectRetryInterval = 10,
Encrypt = SqlConnectionEncryptOption.Mandatory,
TrustServerCertificate = false
}.ConnectionString;
Row-Level Locking for Critical Operations
SELECT * FROM Accounts WITH (UPDLOCK, ROWLOCK) WHERE Id = @Id;
SELECT TOP (@BatchSize) *
FROM Jobs WITH (UPDLOCK, READPAST)
WHERE Status = 'Pending'
ORDER BY CreatedAt;
SELECT * FROM Accounts WITH (UPDLOCK, NOWAIT) WHERE Id = @Id;
public async Task<Account?> GetForUpdateAsync(Guid id, CancellationToken ct)
{
return await context.Accounts
.FromSqlInterpolated(
$"SELECT * FROM Accounts WITH (UPDLOCK, ROWLOCK) WHERE Id = {id}")
.FirstOrDefaultAsync(ct);
}
public async Task<List<Job>> GetPendingJobsAsync(int batchSize, CancellationToken ct)
{
return await context.Jobs
.FromSqlInterpolated($@"
SELECT TOP ({batchSize}) *
FROM Jobs WITH (UPDLOCK, READPAST)
WHERE Status = 'Pending'
ORDER BY CreatedAt")
.ToListAsync(ct);
}
Application Locks (sp_getapplock)
public class AppLockService
{
private readonly ApplicationDbContext _context;
public async Task<bool> TryAcquireLockAsync(
string resourceName, int timeoutMs, CancellationToken ct)
{
var result = await _context.Database
.SqlQuery<int>($@"
DECLARE @result int;
EXEC @result = sp_getapplock
@Resource = {resourceName},
@LockMode = 'Exclusive',
@LockTimeout = {timeoutMs};
SELECT @result")
.FirstAsync(ct);
return result >= 0;
}
public async Task ReleaseLockAsync(string resourceName, CancellationToken ct)
{
await _context.Database
.ExecuteSqlAsync($"EXEC sp_releaseapplock @Resource = {resourceName}", ct);
}
}
public async Task ProcessDailyReportAsync(CancellationToken ct)
{
if (!await _lockService.TryAcquireLockAsync("DailyReport", timeoutMs: 0, ct))
{
_logger.LogInformation("Another instance is processing the daily report");
return;
}
try
{
await GenerateReportAsync(ct);
}
finally
{
await _lockService.ReleaseLockAsync("DailyReport", ct);
}
}
Critical Rules
- Use PascalCase - SQL Server convention for identifiers
- Use nvarchar with explicit lengths - Avoid nvarchar(max) unless needed
- Always use datetimeoffset - Store times in UTC, avoid legacy datetime
- Index foreign keys - SQL Server does NOT auto-create FK indexes
- Use NEWSEQUENTIALID() for GUID PKs - Avoids clustered index fragmentation
- Use rowversion for concurrency - Auto-increments on every row update
- Parameterize queries - Prevent SQL injection
- Monitor index fragmentation - Reorganize at 10%, rebuild at 30%
- Use connection pooling - Enabled by default, tune pool size
- Use EnableRetryOnFailure - Built-in transient fault handling
- Name all constraints - PK_, FK_, CK_, DF_, IX_ prefixes
- Use temporal tables for audit - Built-in history tracking
Related Skills
06-dotnet-ef-core-configuration - EF Core entity configurations
05-dotnet-repository-pattern - Data access patterns
19-dotnet-dapper-query-builder - Raw SQL with Dapper
01-dotnet-clean-architecture - Overall architecture
25.1-dotnet-postgresql-best-practices - PostgreSQL equivalent patterns