.NET Programming With Me

Logging and Structured Diagnostics in .NET 8 with Serilog and PostgreSQL Sink

When something breaks in production at 2 AM, plain-text log lines like "Error processing order" are nearly useless. You cannot filter them, you cannot correlate them across a request, and you cannot query them. Structured logging fixes this by treating every log entry as data with named properties, not a sentence. Serilog is the de facto standard for it in .NET.

By the end of this post you will have a .NET 8 API that logs structured events through Serilog, enriches every entry with request context, writes them to PostgreSQL where you can query them with SQL, and correlates all logs from a single request together.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and an existing ASP.NET Core Web API project
  • A running PostgreSQL instance to act as the log sink
  • The Serilog.AspNetCore and Serilog.Sinks.PostgreSQL NuGet packages
Note: A database is a convenient and queryable sink for moderate volumes, but high-throughput systems usually ship logs to a dedicated platform such as Seq, Elasticsearch, or a cloud log service. The structured-logging principles here apply identically; only the sink changes.

2 Wire up Serilog

The goal is to replace the default logging provider with Serilog as early as possible, so that even startup errors are captured. .NET 8's minimal hosting model makes this clean with a two-stage bootstrap.

Bootstrap logger plus full configuration

The first logger catches failures during host build; the second, configured from appsettings.json, takes over once the app is running.

// Program.cs
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((context, services, config) => config
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.UseSerilogRequestLogging(); // one tidy log line per HTTP request
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly.");
}
finally
{
    Log.CloseAndFlush();
}

UseSerilogRequestLogging() is a quiet hero: it collapses the framework's noisy per-request log spam into a single structured line with method, path, status code, and elapsed time. CloseAndFlush() in the finally block guarantees buffered logs reach the sink before the process exits, which matters most when the app is crashing, exactly when you need those logs.


3 Write structured events

This is the habit shift that makes everything else pay off. Use message templates with named placeholders rather than string interpolation. Serilog captures each value as a queryable property instead of baking it into an opaque string.

// Do this: the values become structured properties
_logger.LogInformation(
    "Order {OrderId} placed by user {UserId} for {Amount:C}",
    order.Id, userId, order.Total);

// Not this: the values are lost inside one flat string
_logger.LogInformation(
    $"Order {order.Id} placed by user {userId} for {order.Total:C}");

With the first form, you can later query "all orders over $500" or "every event for OrderId 4827" directly, because OrderId, UserId, and Amount are real fields. The second form throws that structure away the moment it is written. The difference looks cosmetic; operationally it is enormous.


4 Sink to PostgreSQL

Now we route those structured events into a PostgreSQL table where each property can map to its own column. Defining the column map explicitly keeps the schema predictable and queryable.

// Program.cs — configure the PostgreSQL sink
var columns = new Dictionary<string, ColumnWriterBase>
{
    ["message"]      = new RenderedMessageColumnWriter(),
    ["level"]        = new LevelColumnWriter(),
    ["timestamp"]    = new TimestampColumnWriter(),
    ["exception"]    = new ExceptionColumnWriter(),
    ["properties"]   = new LogEventSerializedColumnWriter(),
    ["request_id"]   = new SinglePropertyColumnWriter("RequestId")
};

builder.Host.UseSerilog((context, services, config) => config
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.PostgreSQL(
        connectionString: context.Configuration.GetConnectionString("Logs")!,
        tableName: "app_logs",
        columnOptions: columns,
        needAutoCreateTable: true));

With needAutoCreateTable set, the sink creates app_logs on first run. The properties column stores the full structured payload as JSON, while common fields get dedicated columns. That hybrid gives you fast filtering on the columns plus the full detail in JSON when you need to dig deeper.

-- Querying logs becomes ordinary SQL
SELECT timestamp, level, message, request_id
FROM app_logs
WHERE level = 'Error'
  AND timestamp > now() - interval '1 hour'
ORDER BY timestamp DESC;
Performance note: writing each log line synchronously to a database will throttle a busy API. Use the sink's batching options (period and batch size) so writes are buffered and flushed in groups, and never log at Debug level in production unless you are actively investigating.

5 Correlate logs per request

The final piece is correlation. When a single request produces ten log lines across services, you need to tie them together. Enriching every log within a request with a shared identifier lets you reconstruct the full story of any one call.

// Middleware that pushes a correlation id into the log context
public sealed class CorrelationMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"]
            .FirstOrDefault() ?? Guid.NewGuid().ToString();

        context.Response.Headers["X-Correlation-ID"] = correlationId;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await next(context);
        }
    }
}
// Program.cs — register before the endpoints run
app.UseMiddleware<CorrelationMiddleware>();

Because Enrich.FromLogContext() is already configured, every log written inside that using block automatically carries the CorrelationId property, with no extra code at the call sites. Returning the id in the response header also lets clients quote it in support tickets, so you can jump straight to their exact request: WHERE properties ->> 'CorrelationId' = '...'.


Wrapping up

You now have an API that logs structured, queryable events through Serilog, enriches them with per-request correlation ids, and persists them to PostgreSQL where ordinary SQL answers your operational questions. That is the difference between guessing what happened in production and knowing.

This closes out Series 5 on real-world .NET 8 API features. Across the five posts you have built authentication, authorization, background processing, caching, and now diagnostics, the cross-cutting concerns that turn a working API into a production-ready one.

Got a question or ran into a problem? Drop a comment below and I will reply.

Caching Strategies in a .NET 8 API: In-Memory, Distributed, and Response Cache

Caching is the cheapest performance win in most APIs and one of the easiest to get subtly wrong. The hard part is rarely storing a value; it is choosing the right layer, picking sensible expirations, and invalidating stale data without serving someone else's. .NET 8 gives you three distinct caching tools, each suited to a different job.

By the end of this post you will understand when to use in-memory caching, distributed caching with Redis, and HTTP response caching, and you will have correct, idiomatic code for all three, including the cache-stampede protection most tutorials leave out.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and an existing ASP.NET Core Web API project
  • A Redis instance for the distributed caching section (a local Docker container works)
  • The Microsoft.Extensions.Caching.StackExchangeRedis package for that same section
Note: The golden rule of caching is to cache data that is read far more often than it changes and that you can tolerate being slightly stale. If correctness on every read is non-negotiable, caching may not be your answer; pick the layer to match how stale you can afford to be.

2 In-memory caching

IMemoryCache stores objects in the process's own memory. It is the fastest option because there is no network hop and no serialization, which makes it ideal for small, frequently read, expensive-to-compute data on a single instance.

The naive version and its flaw

A first attempt usually looks like a get-or-create. It works, but under load it has a hidden problem.

// Cache-aside: check cache, fall back to source, store result
public async Task<Product?> GetProductAsync(int id)
{
    return await _cache.GetOrCreateAsync($"product:{id}", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        return await _db.Products.FindAsync(id);
    });
}

The flaw is the cache stampede: when a popular key expires, many concurrent requests all miss at once and all hit the database simultaneously. On a hot key under traffic, that thundering herd can knock over the very database the cache was meant to protect.

Protecting against the stampede

A per-key lock ensures only one request rebuilds the value while the rest wait for it.

private readonly SemaphoreSlim _gate = new(1, 1);

public async Task<Product?> GetProductSafeAsync(int id)
{
    var key = $"product:{id}";
    if (_cache.TryGetValue(key, out Product? cached))
        return cached;

    await _gate.WaitAsync();
    try
    {
        // Double-check: another thread may have populated it while we waited.
        if (_cache.TryGetValue(key, out cached))
            return cached;

        var product = await _db.Products.FindAsync(id);
        _cache.Set(key, product, TimeSpan.FromMinutes(10));
        return product;
    }
    finally
    {
        _gate.Release();
    }
}

The double-checked pattern inside the lock is what makes this correct: by the time a waiting thread acquires the gate, the value is usually already cached, so it returns immediately without a redundant query.


3 Distributed caching with Redis

In-memory caching breaks down the moment you scale to more than one instance: each server has its own cache, so hit rates drop and invalidation becomes inconsistent. A distributed cache like Redis gives every instance a single shared store.

// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "yourapp:";
});

Because Redis stores bytes, you serialize on the way in and deserialize on the way out. Wrapping that in a small helper keeps call sites clean.

public async Task<T?> GetOrSetAsync<T>(
    string key, Func<Task<T>> factory, TimeSpan ttl)
{
    var cached = await _distributedCache.GetStringAsync(key);
    if (cached is not null)
        return JsonSerializer.Deserialize<T>(cached);

    var value = await factory();

    await _distributedCache.SetStringAsync(
        key,
        JsonSerializer.Serialize(value),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = ttl
        });

    return value;
}

The tradeoff is real: Redis adds a network round trip and serialization cost, so it is slower per call than in-memory. You accept that latency in exchange for a cache that is consistent across every instance and survives an individual app restart.

Edge case: always handle the cache being unavailable. A Redis outage should degrade you to "slower, hitting the database" rather than taking the whole API down. Wrap cache calls so a connection failure falls through to the source instead of throwing.

4 HTTP response caching

The two layers above cache data inside your code. Response caching works at the HTTP level, instructing clients, proxies, and the framework to reuse a whole response. It is the most efficient option when an entire endpoint's output is cacheable, because a cache hit can skip your action method entirely.

// Program.cs
builder.Services.AddResponseCaching();

var app = builder.Build();
app.UseResponseCaching();
[HttpGet("categories")]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = ["page"])]
public IActionResult GetCategories(int page = 1)
{
    return Ok(_catalog.GetCategories(page));
}

VaryByQueryKeys is essential here: without it, the cache could serve page 1's data for a page 2 request. Response caching only applies to GET and HEAD requests that return a success status and carry no Authorization header by default, which is exactly right for public, anonymous, read-heavy endpoints.

Worth knowing: .NET 8 also ships output caching (AddOutputCache), a more powerful server-side cache with tag-based invalidation. Response caching is the standards-based HTTP option; output caching gives you finer control. For new projects, evaluate output caching for server-controlled scenarios.

5 Choosing and testing the right layer

The decision comes down to scope and freshness. Use in-memory for single-instance, ultra-hot, small data. Use Redis when you run multiple instances and need a shared, consistent cache. Use response or output caching when an entire endpoint response is reusable and you want to skip the work altogether.

// A quick way to prove caching works: log and time the source call
public async Task<Product?> GetProductAsync(int id)
{
    return await GetOrSetAsync($"product:{id}", async () =>
    {
        _logger.LogInformation("CACHE MISS for product {Id}", id);
        return await _db.Products.FindAsync(id);
    }, TimeSpan.FromMinutes(10));
}

Call the endpoint twice and you should see exactly one "CACHE MISS" log line, with the second response served from cache and noticeably faster. If you see the miss on every call, your key is varying when it should not (a common cause is including a timestamp or request-specific value in the key).


Wrapping up

You now have three caching layers and a clear rule for each: in-memory for raw speed on one box, Redis for consistency across many, and response or output caching to skip work entirely. Just as importantly, you have stampede protection and graceful degradation, the details that separate a cache that helps from one that becomes a liability.

The final post in this series turns to observability, so you can actually see what your cached, authenticated, job-running API is doing in production: structured logging with Serilog and a PostgreSQL sink.

Got a question or ran into a problem? Drop a comment below and I will reply.

Background Jobs in .NET 8 Web API with IHostedService and Hangfire

Some work has no business running inside an HTTP request. Sending a welcome email, resizing an upload, generating a report, or cleaning up stale records all make the caller wait for something they do not need to wait for. Worse, if the process restarts mid-request, that work is lost. Background jobs solve both problems.

By the end of this post you will know when to reach for the built-in IHostedService versus Hangfire, and you will have working examples of both: a recurring cleanup task with a hosted service, and a durable, retryable fire-and-forget job with Hangfire backed by PostgreSQL.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and an existing ASP.NET Core Web API project
  • A running PostgreSQL instance for the Hangfire storage example
  • The Hangfire.AspNetCore and Hangfire.PostgreSql NuGet packages for the second half
Note: The two tools solve different problems. IHostedService is in-process and ephemeral, ideal for periodic in-memory tasks. Hangfire persists jobs to storage so they survive restarts and retry on failure, which is what you want for work that must not be lost. Choosing the wrong one is the most common mistake here.

2 A recurring task with IHostedService

For periodic in-process work, .NET 8 gives you BackgroundService, a base class over IHostedService that runs alongside your app for its entire lifetime. We will build a worker that purges expired refresh tokens every hour.

Implement the worker

The critical detail is scope. A hosted service is a singleton, but most useful work needs scoped dependencies like a DbContext. You must create a scope per iteration rather than injecting scoped services directly.

// TokenCleanupService.cs
public sealed class TokenCleanupService(
    IServiceScopeFactory scopeFactory,
    ILogger<TokenCleanupService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var removed = await db.RefreshTokens
                    .Where(t => t.ExpiresUtc < DateTime.UtcNow)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation("Purged {Count} expired tokens.", removed);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Token cleanup iteration failed.");
            }
        }
    }
}
// Program.cs
builder.Services.AddHostedService<TokenCleanupService>();

Three things make this production-safe: PeriodicTimer gives clean, allocation-light scheduling that respects the cancellation token; the per-iteration scope prevents the captured-dependency bug; and the try/catch ensures one failed run does not tear down the whole loop. Without that catch, a single transient database hiccup would silently kill the service for the rest of the process lifetime.


3 Durable jobs with Hangfire

IHostedService is great until the requirement becomes "this email must be sent even if the server restarts." That is Hangfire's domain. It persists every enqueued job to storage (PostgreSQL here), executes it on a background worker, and retries automatically on failure.

Register Hangfire

// Program.cs
builder.Services.AddHangfire(config => config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UsePostgreSqlStorage(options =>
        options.UseNpgsqlConnection(
            builder.Configuration.GetConnectionString("Hangfire"))));

builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard("/jobs"); // protect this in production

Hangfire creates its own schema in PostgreSQL on first run. The dashboard at /jobs is a genuinely useful operational window, but it exposes job data, so wrap it in an authorization filter before shipping; an unprotected dashboard is a real security risk.


4 Enqueue and schedule work

With Hangfire registered, queueing work from a controller is a single call that returns immediately. The job runs out-of-band on a worker, and the HTTP response is fast.

// In a controller, after registering a user
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
    var user = await _userService.CreateAsync(dto);

    // Fire-and-forget: returns instantly, runs in the background.
    _backgroundJobs.Enqueue<IEmailSender>(
        sender => sender.SendWelcomeEmailAsync(user.Email));

    return Ok(new { user.Id });
}

Hangfire also handles delayed and recurring jobs. A nightly report or a "remind me in 24 hours" email needs no custom timer code:

// Delayed: run once, later
_backgroundJobs.Schedule<IEmailSender>(
    sender => sender.SendReminderAsync(userId),
    TimeSpan.FromHours(24));

// Recurring: cron-scheduled, survives restarts
_recurringJobs.AddOrUpdate<IReportService>(
    "nightly-report",
    service => service.GenerateDailyReportAsync(),
    Cron.Daily(2)); // 2 AM every day

Because Hangfire passes a type and an expression rather than an instance, it resolves your service from DI at execution time and serializes only the arguments. Keep those arguments small and serializable; passing entire entities is a common pitfall that bloats storage and breaks when the type changes.


5 Running and verifying

Start the app and register a user. The HTTP response returns immediately, then the welcome-email job appears in the Hangfire dashboard, moving from Enqueued to Processing to Succeeded. The hosted cleanup service logs its purge count once an hour.

// Make a job fail deliberately to watch retries in the dashboard
public Task SendWelcomeEmailAsync(string email)
{
    if (string.IsNullOrWhiteSpace(email))
        throw new InvalidOperationException("No email address.");
    // ... send logic
    return Task.CompletedTask;
}

Throw an exception on purpose and you will see Hangfire move the job to the Failed state and then retry it on a back-off schedule, all visible in the dashboard. That automatic retry, with full visibility, is precisely what a hand-rolled timer cannot give you and the main reason to reach for Hangfire when work must not be lost.

Idempotency matters: because Hangfire retries, a job can run more than once. Design jobs so a second execution is harmless (check whether the email was already sent, use upserts, guard with a dedupe key). Assuming exactly-once execution is the single biggest source of background-job bugs.

Wrapping up

You now have both tools in your kit: IHostedService for lightweight, in-process recurring work, and Hangfire for durable, retryable, observable jobs backed by PostgreSQL. The decision rule is simple: if losing the work is acceptable, a hosted service is enough; if it is not, persist it with Hangfire.

Next we tackle another lever for responsiveness and cost: caching, looking at in-memory, distributed, and response caching strategies in a .NET 8 API.

Got a question or ran into a problem? Drop a comment below and I will reply.

Role-Based Authorization in ASP.NET Core with EF Core and PostgreSQL

Authentication tells you who is calling. Authorization decides what they are allowed to do, and getting it wrong is one of the most common, and most damaging, API security failures. Role-based authorization (RBAC) is the workhorse approach: assign users to roles, then gate endpoints by role.

By the end of this post you will have an ASP.NET Core API that stores users and roles in PostgreSQL via EF Core, projects those roles into the user's claims, and enforces them on endpoints with both attribute-based and policy-based authorization.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and a running PostgreSQL instance (local Docker container is fine)
  • The Npgsql.EntityFrameworkCore.PostgreSQL package installed
  • A working JWT authentication setup, since roles are carried as claims inside the token (see the previous post in this series)
Note: Roles can be modeled in two places: inside the token as claims, or looked up from the database on each request. This post puts them in the token for performance, which means role changes only take effect when a new token is issued. That tradeoff matters; we revisit it at the end.

2 Model users and roles in EF Core

RBAC is fundamentally a many-to-many relationship: a user can hold several roles, and a role belongs to many users. We model that explicitly with a join entity so the schema stays clean and queryable in PostgreSQL.

The entities

Keeping roles as their own table (rather than a comma-separated string column) means you can query, audit, and extend them later without a painful migration.

// Entities
public sealed class AppUser
{
    public Guid Id { get; set; }
    public string Email { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}

public sealed class AppRole
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty; // "Admin", "Manager", "User"
    public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}

public sealed class UserRole
{
    public Guid UserId { get; set; }
    public AppUser User { get; set; } = null!;
    public int RoleId { get; set; }
    public AppRole Role { get; set; } = null!;
}

Configure the join in the DbContext

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<UserRole>()
        .HasKey(ur => new { ur.UserId, ur.RoleId });

    modelBuilder.Entity<UserRole>()
        .HasOne(ur => ur.User)
        .WithMany(u => u.UserRoles)
        .HasForeignKey(ur => ur.UserId);

    modelBuilder.Entity<UserRole>()
        .HasOne(ur => ur.Role)
        .WithMany(r => r.UserRoles)
        .HasForeignKey(ur => ur.RoleId);

    modelBuilder.Entity<AppRole>()
        .HasData(
            new AppRole { Id = 1, Name = "Admin" },
            new AppRole { Id = 2, Name = "Manager" },
            new AppRole { Id = 3, Name = "User" });
}

The composite primary key on UserRole prevents duplicate assignments at the database level, and seeding the roles with HasData guarantees they exist after the first migration. Run dotnet ef migrations add InitRoles followed by dotnet ef database update to apply this to PostgreSQL.


3 Project roles into claims at login

For attribute-based role checks to work, the role names must be present in the authenticated user's claims. When a user logs in, we load their roles from PostgreSQL and add each one as a ClaimTypes.Role claim on the token.

// During login, after verifying the password
var user = await _db.Users
    .Include(u => u.UserRoles)
        .ThenInclude(ur => ur.Role)
    .FirstOrDefaultAsync(u => u.Email == request.Email);

var roleNames = user!.UserRoles.Select(ur => ur.Role.Name).ToList();
var accessToken = _tokenService.CreateAccessToken(user.Id, user.Email, roleNames);

The eager Include and ThenInclude matter: without them the role collection is empty and every role check silently fails. ASP.NET Core maps ClaimTypes.Role claims into the role system automatically, so User.IsInRole("Admin") just works once the token carries them.


4 Enforce roles on endpoints

There are two ways to gate access, and mature APIs use both. Attribute-based checks are simple and declarative; policy-based checks centralize complex rules so they are not duplicated across controllers.

Attribute-based, for simple gates

[Authorize(Roles = "Admin")]
[HttpDelete("users/{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id)
{
    // Only Admins reach this line.
    await _userService.DeleteAsync(id);
    return NoContent();
}

[Authorize(Roles = "Admin,Manager")]
[HttpGet("reports")]
public IActionResult GetReports() => Ok(_reportService.GetAll());

Policy-based, for reusable rules

When the same combination of roles guards many endpoints, define it once as a named policy. Changing the rule then happens in a single place rather than across dozens of attributes.

// Program.cs
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanManageContent", policy =>
        policy.RequireRole("Admin", "Manager"));

    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));
});
[Authorize(Policy = "CanManageContent")]
[HttpPost("articles")]
public async Task<IActionResult> CreateArticle([FromBody] ArticleDto dto)
{
    var id = await _articleService.CreateAsync(dto);
    return CreatedAtAction(nameof(CreateArticle), new { id }, null);
}

A request from a user without the required role returns 403 Forbidden (they are authenticated but not permitted), while a request with no valid token returns 401 Unauthorized. Distinguishing those two responses correctly is a sign your pipeline is configured properly.


5 Test the authorization matrix

The fastest way to gain confidence is to test the full matrix: each role against each protected endpoint, plus the anonymous case. A small integration test makes regressions impossible to miss.

[Theory]
[InlineData("Admin",   "/api/users/{id}", "DELETE", 204)]
[InlineData("Manager", "/api/users/{id}", "DELETE", 403)]
[InlineData("User",    "/api/reports",    "GET",    403)]
[InlineData(null,      "/api/reports",    "GET",    401)]
public async Task Endpoints_enforce_roles(
    string? role, string path, string method, int expectedStatus)
{
    var client = _factory.CreateAuthenticatedClient(role);
    var response = await client.SendAsync(new HttpRequestMessage(
        new HttpMethod(method), path));

    Assert.Equal(expectedStatus, (int)response.StatusCode);
}

When this theory passes, you have proof that Admins can delete, Managers cannot, Users are blocked from reports, and anonymous callers get a clean 401. That table is also excellent living documentation of your access rules.

The tradeoff to remember: because roles live in the token, revoking a role does not take effect until the token expires or is refreshed. If you need instant revocation, validate critical roles against the database inside an authorization handler instead, accepting the extra query cost for security-sensitive operations.

Wrapping up

You now have a PostgreSQL-backed RBAC model in EF Core, roles flowing into JWT claims at login, and endpoints protected by both attribute and policy-based authorization, all verified by a test matrix. That is a production-grade foundation for controlling access in any .NET 8 API.

Next in the series we shift from security to throughput: running work outside the request thread with background jobs using IHostedService and Hangfire.

Got a question or ran into a problem? Drop a comment below and I will reply.

JWT Authentication in a .NET 8 Web API: Setup, Claims, and Refresh Tokens

Almost every real-world API eventually needs to answer one question on every request: who is calling, and are they allowed to? JWT (JSON Web Token) authentication is the most common answer in the .NET world, but most tutorials stop at "here is how to validate a token" and skip the parts that actually bite you in production: claims design, token expiry, and refresh tokens.

By the end of this post you will have a .NET 8 Web API that issues signed access tokens, embeds meaningful claims, validates them on protected endpoints, and hands out refresh tokens so users are not forced to log in every fifteen minutes.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK installed (verify with dotnet --version)
  • Visual Studio 2022 (17.8 or later) or VS Code with the C# Dev Kit
  • A working ASP.NET Core Web API project and basic familiarity with dependency injection and middleware ordering
Note: JWT signing keys are secrets. The hard-coded key in this post is for local development only. In production, load it from a secrets manager, Azure Key Vault, or environment variables, never from appsettings.json committed to source control.

2 Configure JWT authentication

The first step is wiring the JWT bearer handler into the request pipeline so that the framework knows how to read, validate, and reject tokens. We start by defining the token settings, then registering the authentication scheme.

Define your token settings

Keeping the issuer, audience, key, and lifetimes in a strongly typed options object avoids magic strings scattered across the codebase.

// JwtSettings.cs — bound from configuration
public sealed class JwtSettings
{
    public string Issuer { get; init; } = string.Empty;
    public string Audience { get; init; } = string.Empty;
    public string SigningKey { get; init; } = string.Empty;
    public int AccessTokenMinutes { get; init; } = 15;
    public int RefreshTokenDays { get; init; } = 7;
}
// appsettings.json
{
  "Jwt": {
    "Issuer": "https://api.yourapp.com",
    "Audience": "https://yourapp.com",
    "SigningKey": "DEV-ONLY-replace-with-a-32+-char-secret-from-vault",
    "AccessTokenMinutes": 15,
    "RefreshTokenDays": 7
  }
}

Register the bearer handler

This is where the validation rules live. Notice that every Validate* flag is explicitly set to true; relying on defaults is how subtle security holes slip in.

// Program.cs
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
builder.Services.AddSingleton(jwt);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwt.Issuer,
            ValidAudience = jwt.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddAuthorization();

The key detail most people miss is ClockSkew. It defaults to five minutes, which means a "15 minute" token actually lives for up to 20. Tightening it to 30 seconds keeps short-lived tokens genuinely short-lived. Also remember that UseAuthentication() must come before UseAuthorization() in the middleware pipeline, or every request will be treated as anonymous.


3 Issue access tokens with claims

A token is only as useful as the claims it carries. Claims are the key-value statements the API trusts because the token is signed. Good claim design means you rarely have to hit the database again just to know who the caller is or what they can do.

// TokenService.cs
public sealed class TokenService(JwtSettings settings)
{
    public string CreateAccessToken(Guid userId, string email, IEnumerable<string> roles)
    {
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new(JwtRegisteredClaimNames.Email, email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: settings.Issuer,
            audience: settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(settings.AccessTokenMinutes),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

The sub claim holds the stable user identifier, jti gives each token a unique ID (useful if you ever need to blocklist a specific token), and the role claims drive authorization. Keep tokens lean; do not stuff a user's entire profile in here, because the token travels on every request and large tokens hurt both bandwidth and header size limits.


4 Add refresh tokens

Access tokens are deliberately short-lived, so without a refresh mechanism your users would be logged out constantly. A refresh token is a long-lived, single-use, server-tracked secret that can be exchanged for a fresh access token. Because it is stored and revocable, it gives you a way to invalidate sessions that a stateless JWT alone cannot.

// RefreshToken.cs — persisted entity
public sealed class RefreshToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string TokenHash { get; set; } = string.Empty;
    public DateTime ExpiresUtc { get; set; }
    public DateTime? RevokedUtc { get; set; }
    public bool IsActive => RevokedUtc is null && DateTime.UtcNow < ExpiresUtc;
}
// AuthController.cs — the refresh endpoint
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
    var hash = TokenHasher.Hash(request.RefreshToken);
    var stored = await _db.RefreshTokens
        .FirstOrDefaultAsync(t => t.TokenHash == hash);

    if (stored is null || !stored.IsActive)
        return Unauthorized("Invalid or expired refresh token.");

    // Rotate: revoke the old token and issue a new pair.
    stored.RevokedUtc = DateTime.UtcNow;

    var user = await _db.Users.FindAsync(stored.UserId);
    var (newRefresh, newHash) = TokenHasher.Generate();

    _db.RefreshTokens.Add(new RefreshToken
    {
        UserId = user!.Id,
        TokenHash = newHash,
        ExpiresUtc = DateTime.UtcNow.AddDays(_settings.RefreshTokenDays)
    });
    await _db.SaveChangesAsync();

    var accessToken = _tokenService.CreateAccessToken(
        user.Id, user.Email, user.Roles);

    return Ok(new { accessToken, refreshToken = newRefresh });
}

Two non-negotiable practices appear here. First, store only a hash of the refresh token, never the raw value, so a database leak does not hand attackers live sessions. Second, rotate on every use: each refresh revokes the old token and issues a new one, which lets you detect token theft (a revoked token being replayed is a strong signal something is wrong).

Edge case: If a revoked refresh token is presented, treat it as a possible breach. A robust implementation revokes the entire token family for that user, forcing a fresh login. This is the difference between a tidy demo and a system you can defend.

5 Protect endpoints and test the flow

With issuance and refresh in place, protecting an endpoint is a single attribute. The framework reads the bearer token, validates the signature and claims, and populates User for you.

[Authorize]
[HttpGet("me")]
public IActionResult Me()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
    var email  = User.FindFirstValue(JwtRegisteredClaimNames.Email);
    return Ok(new { userId, email });
}

To verify the full loop, call your login endpoint to receive an access and refresh token, then call a protected endpoint with the access token in the header:

GET /api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

A valid token returns 200 with your claims. Wait for the access token to expire and the same call returns 401; hit the refresh endpoint with your refresh token and you get a fresh pair, confirming the rotation works end to end.


Wrapping up

You now have a .NET 8 API that issues signed, claim-bearing access tokens, validates them strictly, and supports rotating refresh tokens backed by hashed, revocable storage. That covers the authentication half of the security story: proving who the caller is.

The natural next step is authorization, deciding what an authenticated caller is allowed to do. That is exactly what the next post in this series tackles with role-based authorization using EF Core and PostgreSQL.

Got a question or ran into a problem? Drop a comment below and I will reply.

Connection Pooling with Npgsql and .NET 8: PgBouncer, Multiplexing, and Best Practices

Connection pooling is more critical with PostgreSQL than it is with SQL Server, and the reason is architectural. Each PostgreSQL client connection spawns a dedicated OS process on the server, making connections expensive to establish and memory-intensive to hold. Under load, exhausting that process limit is one of the most common scaling failures for .NET APIs backed by PostgreSQL.

This post walks through three layers of the solution: Npgsql's built-in client-side pool, the multiplexing mode introduced in Npgsql 7, and PgBouncer as a server-side proxy for high-concurrency deployments. By the end you will have a clear picture of which layer solves which problem and what configuration to use in a .NET 8 API.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK or later
  • PostgreSQL 14 or later
  • Npgsql 8.x (the standalone driver) or Npgsql.EntityFrameworkCore.PostgreSQL 8.x
  • PgBouncer installed locally or via Docker if you want to follow the PgBouncer section
Note: PostgreSQL's default max_connections is 100. Each connection consumes roughly 5–10 MB of RAM on the server. A single .NET API instance with a default Npgsql pool size of 100 can exhaust max_connections on its own, before any other clients connect.

2 Npgsql's built-in client-side pool

Npgsql maintains a pool of physical connections per unique connection string. When your code opens a logical connection (DbContext method call, NpgsqlConnection.OpenAsync()), Npgsql hands out a connection from the pool rather than opening a new one. When the logical connection is closed or the DbContext is disposed, the physical connection returns to the pool for reuse.

Configuring the pool via NpgsqlDataSourceBuilder

In .NET 8, the recommended approach is to build an NpgsqlDataSource once at startup and register it with the DI container. This is more efficient than passing a raw connection string because Npgsql can reuse prepared statement caches and configuration across the application lifetime.

// Program.cs
var connectionString = builder.Configuration.GetConnectionString("Default")!;

var dataSource = new NpgsqlDataSourceBuilder(connectionString)
    .Build();

builder.Services.AddSingleton(dataSource);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(dataSource));

Key connection string pool parameters

// appsettings.json connection string with explicit pool settings
// "Server=localhost;Port=5432;Database=mydb;Username=api;Password=secret;
//  Minimum Pool Size=2;Maximum Pool Size=20;
//  Connection Idle Lifetime=300;Connection Pruning Interval=10"

var builder = new NpgsqlConnectionStringBuilder(connectionString)
{
    MinPoolSize = 2,            // Keep at least 2 connections open at all times
    MaxPoolSize = 20,           // Never exceed 20 physical connections per instance
    ConnectionIdleLifetime = 300,  // Close idle connections after 5 minutes
    ConnectionPruningInterval = 10 // Check for idle connections every 10 seconds
};

Size the pool relative to your workload and the number of application instances. If you run 5 replicas each with MaxPoolSize=20, you need PostgreSQL's max_connections set above 100 (5 × 20), with headroom for admin connections. A common production formula is MaxPoolSize = max_connections / number_of_instances - buffer.

Tip: Do not set MaxPoolSize too high. More connections means more RAM consumed on the PostgreSQL server, more lock contention, and more context switching. A smaller pool with a short wait timeout is often faster than a large pool where the server is thrashing. Start at 20 per instance and tune from there.

3 Multiplexing mode in Npgsql 7+

Npgsql's multiplexing mode allows multiple concurrent commands to share a single physical connection through pipelining. Instead of each async operation waiting for an exclusive connection from the pool, multiplexing queues commands and sends them over shared connections, reducing the total number of physical connections needed under high concurrency.

Enabling multiplexing

var dataSource = new NpgsqlDataSourceBuilder(connectionString)
    .EnableDynamicJson()  // optional; keeps JSON handling flexible
    .Build();

// Multiplexing is enabled via the connection string parameter
// Add "Multiplexing=true;Max Auto Prepare=20" to your connection string:
// "...;Multiplexing=true;Max Auto Prepare=20"
// Or configure it on the builder directly:
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
    Multiplexing = true,
    MaxAutoPrepare = 20   // Npgsql auto-prepares the 20 most-used statements
};

var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();

When multiplexing helps and when it does not

Multiplexing reduces the connection count under high-concurrency, short-duration query workloads. It is most effective when many requests are running simple queries simultaneously, such as a read-heavy API endpoint with hundreds of concurrent users.

Multiplexing is not compatible with all Npgsql features. Do not enable it if your code uses any of the following, as they require exclusive connection ownership:

  • Explicit transactions (BeginTransactionAsync)
  • COPY operations
  • LISTEN / NOTIFY
  • Cursors or portal-based streaming
Note: EF Core wraps most operations in implicit transactions, which are incompatible with multiplexing. If you use EF Core as your primary data access layer with SaveChangesAsync(), multiplexing provides limited benefit and may cause errors. It is better suited to scenarios using raw NpgsqlCommand or Dapper without explicit transactions.

4 PgBouncer as a server-side pool

When you have many application instances (microservices, containerised replicas, serverless functions) each maintaining their own Npgsql pool, the total connection count against PostgreSQL can still grow beyond what the server handles efficiently. PgBouncer is a lightweight connection pooler that sits between your .NET applications and PostgreSQL, multiplexing many client connections into a smaller number of server connections.

PgBouncer pooling modes

Session mode assigns one server connection per client connection for the full session lifetime. This is the most compatible mode but provides the least reduction in server-side connections. Use it as a safe default when first introducing PgBouncer.

Transaction mode assigns a server connection only for the duration of a single transaction, then releases it immediately. This is the most efficient mode and provides the largest reduction in server-side connections, but it is incompatible with session-level features: prepared statements, advisory locks, SET settings, and temporary tables all break in transaction mode unless handled carefully.

Configuring Npgsql for PgBouncer transaction mode

// When using PgBouncer in transaction mode, disable Npgsql's prepared statement
// cache and set No Reset On Close to skip the session-cleanup command
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
    // Point at PgBouncer's port (default 6432), not PostgreSQL directly
    Host = "pgbouncer-host",
    Port = 6432,

    // Disable server-side prepared statements (not supported in transaction mode)
    MaxAutoPrepare = 0,
    NoResetOnClose = true,   // Skip "DISCARD ALL" on connection return

    // Keep Npgsql's client pool small; PgBouncer owns the server-side pool
    MaxPoolSize = 10
};

var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();
builder.Services.AddDbContext<AppDbContext>(o => o.UseNpgsql(dataSource));

Minimal PgBouncer configuration for a .NET API

; pgbouncer.ini
[databases]
mydb = host=postgres-host port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000    ; total clients PgBouncer accepts
default_pool_size = 25    ; server connections per database/user pair
server_reset_query =      ; empty: skip DISCARD ALL in transaction mode
Tip: Run PgBouncer as a sidecar container alongside each application pod in Kubernetes rather than as a single shared instance. A sidecar keeps the network hop local and removes a single point of failure, while each pod's PgBouncer contributes its default_pool_size to the total server-side connection count you need to budget for.

5 Running and verifying your pool

After configuring pooling, verify the actual connection count on the PostgreSQL server to confirm your settings are having the intended effect.

-- Count active connections by application and state
SELECT
    application_name,
    state,
    count(*) AS connection_count
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY application_name, state
ORDER BY connection_count DESC;

-- Check if any connections are waiting for a pool slot (wait_event_type = 'Client')
SELECT pid, wait_event_type, wait_event, query_start, state
FROM pg_stat_activity
WHERE wait_event_type = 'Client'
  AND datname = 'mydb';
// In .NET 8, log Npgsql pool events to watch for pool exhaustion at startup
builder.Logging.AddFilter("Npgsql", LogLevel.Information);

// Pool exhaustion looks like this in logs:
// The connection pool has been exhausted, either raise MaxPoolSize
// or set ConnectionTimeout to a higher value.

Wrapping up

Connection pooling with PostgreSQL and .NET requires thinking at three levels. Npgsql's client-side pool handles the common case for a single application instance: size it conservatively and use NpgsqlDataSourceBuilder for lifetime management. Multiplexing is a useful additional tool for raw command workloads with very high concurrency and no transactions. PgBouncer steps in when you have many application instances and need to cap the total server-side connection count below what PostgreSQL can efficiently handle.

The most important thing you can do before tuning is measure: check pg_stat_activity under realistic load, watch for pool exhaustion in logs, and size your pool relative to max_connections and the number of application replicas actually running.

Got a question or ran into a problem? Drop a comment below and I will reply.

Indexing Strategies in PostgreSQL That Every .NET Developer Should Know

Most .NET developers let EF Core generate their PostgreSQL indexes and then wonder why certain queries are slow in production. The default behaviour is reasonable, but PostgreSQL offers several index types that SQL Server does not, and knowing when to reach for each one is the difference between a fast API and a scaling problem.

This post covers the four index strategies that matter most for .NET API workloads: B-Tree, GIN, partial, and expression indexes. Each section explains what the index is for, shows how to create it (both via EF Core and raw SQL), and includes a EXPLAIN ANALYZE pattern to verify it is being used.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and PostgreSQL 14 or later
  • Npgsql.EntityFrameworkCore.PostgreSQL 8.x
  • Access to psql, pgAdmin, or DataGrip to run EXPLAIN ANALYZE
  • A working EF Core project with at least one entity mapped to a table
Note: Index decisions should always be validated with realistic data volumes. An index that helps at 100,000 rows may be ignored at 500 rows because the query planner considers a sequential scan cheaper on small tables.

2 B-Tree indexes — the default

Every HasIndex() call in EF Core creates a B-Tree index. B-Tree is the right choice for equality and range filters on scalar columns: integers, GUIDs, dates, strings, and decimals. If you are filtering by UserId, sorting by CreatedAt, or looking up by Email, B-Tree is what you want.

Creating B-Tree indexes with EF Core

modelBuilder.Entity<Order>(entity =>
{
    // Single-column index for equality lookups
    entity.HasIndex(o => o.CustomerId);

    // Composite index — column order matters
    // Queries that filter on Status AND sort by CreatedAt benefit from this
    entity.HasIndex(o => new { o.Status, o.CreatedAt });

    // Unique index — enforced at the database level
    entity.HasIndex(o => o.ReferenceNumber).IsUnique();
});

Composite index column ordering

For composite B-Tree indexes, put the highest-cardinality equality filter column first, followed by range or sort columns. A query that filters on Status = 'Pending' and orders by CreatedAt DESC uses the index above efficiently. A query that only filters on CreatedAt cannot use the leftmost prefix and will do a sequential scan.

-- Confirm index usage for the composite index
EXPLAIN ANALYZE
SELECT * FROM "Orders"
WHERE "Status" = 'Pending'
ORDER BY "CreatedAt" DESC
LIMIT 50;
-- Look for: Index Scan Backward using "IX_Orders_Status_CreatedAt"
Tip: A covering index (PostgreSQL 11+) stores additional columns in the index leaf nodes, allowing the query to be satisfied without touching the heap. Use IncludeProperties() in EF Core to add include columns to a B-Tree index.
// Covering index: the query planner can serve the SELECT entirely from the index
entity.HasIndex(o => new { o.Status, o.CreatedAt })
    .IncludeProperties(o => new { o.Id, o.TotalAmount });

3 GIN indexes — for JSONB, arrays, and full-text search

GIN (Generalised Inverted Index) indexes the internal structure of a composite value rather than the value itself. Use GIN for jsonb columns, PostgreSQL array columns, and tsvector full-text search columns. A B-Tree index on a jsonb column is useless for containment queries; GIN is what makes them fast.

GIN index on a JSONB column

// EF Core does not expose GIN natively; use a raw SQL migration
public partial class AddMetadataGinIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Full-document GIN index — covers all keys and values in the document
        migrationBuilder.Sql(
            """
            CREATE INDEX ix_products_metadata_gin
            ON "Products"
            USING GIN ("Metadata");
            """
        );
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            """DROP INDEX IF EXISTS ix_products_metadata_gin;"""
        );
    }
}

GIN index on a tsvector column

// When using HasGeneratedTsVectorColumn, chain HasMethod("GIN")
modelBuilder.Entity<Article>()
    .HasGeneratedTsVectorColumn(a => a.SearchVector, "english",
        a => new { a.Title, a.Body })
    .HasIndex(a => a.SearchVector)
    .HasMethod("GIN");  // Npgsql honours this for tsvector columns
Tip: GIN indexes are larger and slower to build than B-Tree indexes, but they are dramatically faster for containment and match queries on complex types. Use gin_pending_list_limit (default 4 MB) to control how much data is buffered in a pending list before being merged into the main index structure.

4 Partial indexes — index a subset of rows

A partial index includes only the rows that match a WHERE clause. If 95% of your Orders table has Status = 'Completed', an index that covers all statuses is bloated with rows you rarely query. A partial index on Status = 'Pending' is smaller, faster to scan, and faster to update because only new pending orders touch it.

Creating a partial index with EF Core

modelBuilder.Entity<Order>(entity =>
{
    // Only index rows where the order is not yet completed
    entity.HasIndex(o => o.CreatedAt)
        .HasFilter("\"Status\" != 'Completed'");
});
-- Equivalent SQL (generated by the migration):
CREATE INDEX "IX_Orders_CreatedAt"
ON "Orders" ("CreatedAt")
WHERE "Status" != 'Completed';

Soft-delete pattern with a partial index

// A very common pattern: index only non-deleted rows
modelBuilder.Entity<Customer>(entity =>
{
    entity.HasIndex(c => c.Email)
        .IsUnique()
        .HasFilter("\"IsDeleted\" = false");
    // This also enforces unique emails only among active customers
});

This pattern is particularly useful for soft-delete implementations: the unique constraint fires only for active records, and the index is small because deleted rows are excluded.


5 Expression indexes — index a computed value

An expression index (also called a functional index) indexes the result of an expression rather than a raw column value. Use this when your queries consistently apply a function to a column before filtering, such as lower-casing an email for case-insensitive lookups or extracting a date from a timestamp.

Case-insensitive email lookup

-- Create via a raw SQL migration (EF Core HasIndex does not support expressions)
CREATE INDEX ix_customers_email_lower
ON "Customers" (lower("Email"));
// Your LINQ query must use the same expression for the index to apply
var customer = await context.Customers
    .Where(c => c.Email.ToLower() == email.ToLower())
    .FirstOrDefaultAsync();

// EF Core + Npgsql translates ToLower() to lower(), which matches the index

Indexing a date extracted from a timestamp

-- Queries that filter on the date part of a timestamptz column
CREATE INDEX ix_orders_created_date
ON "Orders" (date_trunc('day', "CreatedAt" AT TIME ZONE 'UTC'));
Note: The expression in a LINQ Where clause must match the index expression exactly for the planner to use it. If the index is on lower("Email") but your query sends "Email" ILIKE $1, the index will not be used. Always verify with EXPLAIN ANALYZE.

6 Finding missing and unused indexes

PostgreSQL tracks index usage statistics in pg_stat_user_indexes. After running your application under realistic load, query this view to identify indexes that are never used (candidates for removal) and tables with high sequential scan counts but no supporting index (candidates for a new index).

-- Indexes that have never been used since the last statistics reset
SELECT
    schemaname,
    relname    AS table_name,
    indexrelname AS index_name,
    idx_scan   AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname = 'public'
ORDER BY relname;

-- Tables with high sequential scans (potential missing indexes)
SELECT
    relname    AS table_name,
    seq_scan,
    idx_scan,
    n_live_tup AS row_estimate
FROM pg_stat_user_tables
WHERE seq_scan > 100
  AND schemaname = 'public'
ORDER BY seq_scan DESC;

An unused index still incurs write overhead on every INSERT, UPDATE, and DELETE. Remove indexes that have never been used after several weeks of production traffic. Statistics are reset by pg_stat_reset() or when PostgreSQL restarts, so collect data over a meaningful time window before drawing conclusions.


Wrapping up

PostgreSQL's index toolkit is broader than most SQL Server developers realise. B-Tree covers the common cases that EF Core's HasIndex() already generates. GIN unlocks efficient querying of jsonb documents, arrays, and full-text search vectors. Partial indexes keep write-heavy tables lean by excluding rows you rarely read. Expression indexes push function application into the index so query-time function calls do not bypass it.

The consistent discipline across all four types is the same: define the index in a migration, load representative data, run EXPLAIN ANALYZE on your actual queries, and check pg_stat_user_indexes after the application has been under load. Never assume an index is being used without verifying it.

Got a question or ran into a problem? Drop a comment below and I will reply.

Full-Text Search in PostgreSQL from a .NET 8 EF Core Controller API

Adding keyword search to an API often leads .NET developers toward Elasticsearch or Azure Cognitive Search before they have even looked at what their database already supports. PostgreSQL's built-in full-text search, backed by a GIN index, handles a large class of search requirements without any external service, and Npgsql exposes it cleanly in LINQ.

This post walks through configuring a generated tsvector column on a PostgreSQL table, indexing it correctly, and wiring up a working search endpoint in an ASP.NET Core Controller API using EF Core 8.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK or later
  • PostgreSQL 14 or later
  • Npgsql.EntityFrameworkCore.PostgreSQL 8.x NuGet package
  • An ASP.NET Core Web API project with a configured DbContext
Note: All FTS examples use the english language configuration. PostgreSQL ships with configurations for many languages. Run SELECT cfgname FROM pg_ts_config; in psql to see what is available on your instance.

2 How PostgreSQL full-text search works

PostgreSQL FTS uses two building blocks. A tsvector is a pre-processed, sorted list of lexemes (normalised word roots) derived from a text document. A tsquery is a search expression with AND (&), OR (|), NOT (!), and phrase (<->) operators. The @@ match operator checks whether a tsquery matches a tsvector.

-- What tsvector looks like for a sentence:
SELECT to_tsvector('english', 'Building scalable APIs with .NET 8 and PostgreSQL');
-- Result: '.net':5 '8':6 'api':4 'build':1 'postgresql':8 'scalabl':3

-- What tsquery looks like for a user search term:
SELECT plainto_tsquery('english', 'scalable apis');
-- Result: 'scalabl' & 'api'

-- The match operator:
SELECT to_tsvector('english', 'Building scalable APIs with .NET 8')
    @@ plainto_tsquery('english', 'scalable apis');
-- Result: true

The key insight is that both sides are reduced to lexemes: "Building" becomes "build", "APIs" becomes "api", "scalable" becomes "scalabl". This is why FTS finds "APIs" when you search for "api" and why it ignores stop words like "with" and "and".


3 Adding a generated tsvector column

Rather than recomputing the tsvector on every search query, store it as a generated (computed) column that PostgreSQL keeps up to date automatically whenever the source columns change. Npgsql exposes a dedicated fluent API for this pattern.

The entity

using NpgsqlTypes;

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public DateTime PublishedAt { get; set; }

    // Populated automatically by PostgreSQL; never set this from C#
    public NpgsqlTsVector SearchVector { get; set; } = null!;
}

Configuring the generated column and GIN index

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Article>(entity =>
    {
        entity.HasKey(a => a.Id);

        // Tell Npgsql to create a GENERATED ALWAYS AS tsvector column
        // weighting Title more heavily than Body (A = highest, D = lowest)
        entity.HasGeneratedTsVectorColumn(
                a => a.SearchVector,
                "english",
                a => new { a.Title, a.Body })
            .HasIndex(a => a.SearchVector)
            .HasMethod("GIN");
    });
}

What the migration generates

-- Npgsql emits a STORED generated column backed by to_tsvector:
ALTER TABLE "Articles"
    ADD COLUMN "SearchVector" tsvector
    GENERATED ALWAYS AS (
        to_tsvector('english',
            coalesce("Title", '') || ' ' || coalesce("Body", ''))
    ) STORED;

CREATE INDEX "IX_Articles_SearchVector"
    ON "Articles" USING GIN ("SearchVector");

Run dotnet ef migrations add AddArticleSearchVector and dotnet ef database update. Inspect the migration to confirm both the generated column and the GIN index are present before applying.

Tip: If you need to weight the title higher than the body (so that a title match ranks above a body match), use a raw SQL migration to create the generated column manually with setweight(to_tsvector('english', "Title"), 'A') || setweight(to_tsvector('english', "Body"), 'B'). Npgsql's HasGeneratedTsVectorColumn concatenates columns without weights.

4 Writing the search endpoint

With the column and index in place, the Controller endpoint is straightforward. Use PlainToTsQuery rather than ToTsQuery for user-supplied input: PlainToTsQuery treats the input as plain text without requiring valid tsquery syntax, so it will not throw on arbitrary search strings.

The Controller

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NpgsqlTypes;

[ApiController]
[Route("api/[controller]")]
public class ArticlesController(AppDbContext context) : ControllerBase
{
    [HttpGet("search")]
    public async Task<IActionResult> Search(
        [FromQuery] string q,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20)
    {
        if (string.IsNullOrWhiteSpace(q))
            return BadRequest("A search term is required.");

        // PlainToTsQuery is safe for arbitrary user input
        var tsQuery = EF.Functions.PlainToTsQuery("english", q.Trim());

        var results = await context.Articles
            .Where(a => a.SearchVector.Matches(tsQuery))
            .OrderByDescending(a => a.SearchVector.Rank(tsQuery))
            .Select(a => new ArticleSearchResult(
                a.Id,
                a.Title,
                a.Author,
                a.PublishedAt,
                a.Body.Length > 300 ? a.Body.Substring(0, 300) + "…" : a.Body))
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

        return Ok(results);
    }
}

public record ArticleSearchResult(
    int Id,
    string Title,
    string Author,
    DateTime PublishedAt,
    string Excerpt);

What EF Core sends to PostgreSQL

SELECT a."Id", a."Title", a."Author", a."PublishedAt",
    CASE WHEN length(a."Body") > 300
         THEN substring(a."Body", 1, 300) || '…'
         ELSE a."Body" END
FROM "Articles" AS a
WHERE a."SearchVector" @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(a."SearchVector", plainto_tsquery('english', $1)) DESC
LIMIT $2 OFFSET $3
Tip: WebSearchToTsQuery is an alternative to PlainToTsQuery available in PostgreSQL 11+. It understands Google-style syntax: quoted phrases, the -word exclusion operator, and OR. Use it when you want to expose advanced search syntax to users without building a custom parser.

5 Running and testing it

Seed the table with a few articles, then test the endpoint with representative queries to confirm the GIN index is being used.

// Quick seed in Program.cs (development only)
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();

    if (!await db.Articles.AnyAsync())
    {
        db.Articles.AddRange(
            new Article { Title = "Getting started with EF Core 8", Body = "Entity Framework Core 8 introduces JSON columns, bulk updates, and complex type mappings.", Author = "Anaya", PublishedAt = DateTime.UtcNow.AddDays(-10) },
            new Article { Title = "PostgreSQL indexing strategies", Body = "B-Tree, GIN, and partial indexes each serve a different purpose in PostgreSQL.", Author = "Anaya", PublishedAt = DateTime.UtcNow.AddDays(-5) }
        );
        await db.SaveChangesAsync();
    }
}
-- Confirm the GIN index is used (run in psql or pgAdmin)
EXPLAIN ANALYZE
SELECT * FROM "Articles"
WHERE "SearchVector" @@ plainto_tsquery('english', 'entity framework');

-- Look for: Bitmap Index Scan on "IX_Articles_SearchVector"
-- or: Index Scan using "IX_Articles_SearchVector"

A successful result shows a Bitmap Index Scan or Index Scan against IX_Articles_SearchVector, not a sequential scan. If you see a sequential scan on a freshly populated table, run ANALYZE "Articles"; to update the planner statistics and re-check.


Wrapping up

PostgreSQL's built-in full-text search, combined with Npgsql's HasGeneratedTsVectorColumn and the Matches LINQ extension, gives you a capable search endpoint without any external service. The generated column keeps the tsvector current automatically, the GIN index keeps searches fast at scale, and PlainToTsQuery handles arbitrary user input safely.

For the next step, consider adding highlighted snippets using PostgreSQL's ts_headline() function via EF.Functions or a raw SQL projection, and look at pg_trgm if you need fuzzy matching for misspellings alongside FTS relevance ranking.

Got a question or ran into a problem? Drop a comment below and I will reply.

JSONB Columns in PostgreSQL with EF Core: When and How to Use Them

PostgreSQL's jsonb column type is not a glorified text field for stashing serialised strings. It is a binary-indexed, queryable, first-class storage format that gives you document-style flexibility inside a relational schema, without reaching for a separate document database. If you have ever pushed a JSON string into an nvarchar(max) column and then written ugly string parsing to query it, jsonb is the answer you were looking for.

EF Core 8 introduced proper JSON column support that Npgsql maps directly to jsonb. This post walks through defining the mapping, writing LINQ queries that PostgreSQL executes efficiently, and adding the right index so that power is not wasted on full scans.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK or later
  • PostgreSQL 14 or later (16 is recommended)
  • Npgsql.EntityFrameworkCore.PostgreSQL 8.x NuGet package
  • Familiarity with EF Core migrations and basic LINQ queries
Note: EF Core's owned entity ToJson() mapping requires EF Core 7 or later. All examples here use EF Core 8. Npgsql maps ToJson() to a jsonb column by default; it does not emit json (text) or nvarchar.

2 Defining a JSONB column

EF Core 8 uses owned entity types configured with ToJson() to project a C# object into a single JSON column. You define the C# shape normally, then tell EF Core to store it as JSON rather than as a separate table.

The entity model

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    // This navigation property will become a single jsonb column
    public ProductDetails? Details { get; set; }
}

public class ProductDetails
{
    public string? Sku { get; set; }
    public int StockQuantity { get; set; }
    public List<string> Tags { get; set; } = new();
    public Dictionary<string, string> Attributes { get; set; } = new();
}

Configuring the mapping

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(p => p.Id);

            entity.OwnsOne(p => p.Details, details =>
            {
                details.ToJson(); // Npgsql emits: "Details" jsonb
            });
        });
    }
}

What the migration generates

// The generated migration contains:
migrationBuilder.AddColumn<string>(
    name: "Details",
    table: "Products",
    type: "jsonb",      // Npgsql sets this; SQL Server would emit nvarchar(max)
    nullable: true);

Run dotnet ef migrations add AddProductDetails followed by dotnet ef database update. Inspect the generated migration file to confirm the column type is jsonb before applying it.

Tip: jsonb stores JSON in a decomposed binary format. PostgreSQL parses and validates the document on write, making writes slightly heavier than the json (plain text) type, but reads are faster because the document does not need re-parsing on every access.

3 Querying JSONB columns with LINQ

This is where jsonb earns its place. EF Core translates LINQ expressions against the owned type into PostgreSQL JSON path operators, which execute server-side without pulling every row into memory.

Filtering on a scalar JSON property

// Find all products currently in stock
var inStock = await context.Products
    .Where(p => p.Details != null && p.Details.StockQuantity > 0)
    .OrderBy(p => p.Name)
    .ToListAsync();

// PostgreSQL executes something equivalent to:
// WHERE (details ->> 'StockQuantity')::int > 0

Filtering with a JSON array containment check

// Find products tagged as "clearance"
var clearance = await context.Products
    .Where(p => p.Details != null && p.Details.Tags.Contains("clearance"))
    .ToListAsync();

Combining relational and JSON filters

// Under $50, in stock, and tagged as "sale"
var saleItems = await context.Products
    .Where(p =>
        p.Price < 50m &&
        p.Details != null &&
        p.Details.StockQuantity > 0 &&
        p.Details.Tags.Contains("sale"))
    .Select(p => new
    {
        p.Id,
        p.Name,
        p.Price,
        p.Details!.Sku
    })
    .ToListAsync();

Updating a JSON document

// Load, modify in memory, and save — EF Core tracks the change
var product = await context.Products
    .FirstOrDefaultAsync(p => p.Id == productId);

if (product?.Details is not null)
{
    product.Details.StockQuantity -= quantitySold;
    product.Details.Tags.Add("low-stock");
    await context.SaveChangesAsync();
}
// EF Core sends the full updated jsonb document back to PostgreSQL
Note: EF Core replaces the entire jsonb document on update, not individual fields. For high-frequency partial updates to large documents, consider a raw SQL jsonb_set() call via ExecuteSqlRawAsync to minimise the payload.

4 Adding a GIN index for performance

Without an index every jsonb query performs a sequential scan. A GIN (Generalised Inverted Index) lets PostgreSQL push filters into the index structure rather than reading every row.

Full-document GIN index via a custom migration

EF Core's fluent API does not expose GIN index creation natively, so you add it in a raw SQL migration.

public partial class AddProductDetailsGinIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            """
            CREATE INDEX ix_products_details_gin
            ON "Products"
            USING GIN ("Details");
            """
        );
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            """DROP INDEX IF EXISTS ix_products_details_gin;"""
        );
    }
}

Targeted expression indexes for specific paths

A full-document GIN index covers every key in the document but can grow large. If you only filter on one or two paths, a targeted expression index is leaner and faster for those specific queries.

-- Index just the StockQuantity path, cast to integer
CREATE INDEX ix_products_stock_qty
ON "Products" (( ("Details" ->> 'StockQuantity')::int ));

-- GIN index on only the Tags array within the document
CREATE INDEX ix_products_tags_gin
ON "Products"
USING GIN (("Details" -> 'Tags'));

Verifying the index is used

-- Run in psql or pgAdmin after loading representative data
EXPLAIN ANALYZE
SELECT * FROM "Products"
WHERE ("Details" ->> 'StockQuantity')::int > 0;

-- Look for "Index Scan using ix_products_stock_qty" in the output.
-- If you see "Seq Scan", run ANALYZE "Products"; to refresh statistics.
Note: PostgreSQL's query planner may choose a sequential scan on small tables even with an index present, because the cost model favours sequential I/O at low row counts. Test index usage with a realistic data volume before concluding the index is not working.

Wrapping up

EF Core 8 and Npgsql together make jsonb columns a first-class part of your .NET data model. You get strongly typed C# objects, LINQ translation to efficient PostgreSQL JSON path operators, and index support that keeps reads fast as rows accumulate.

Use jsonb when your schema genuinely varies across rows or when you need semi-structured metadata alongside relational data. Do not use it as an excuse to avoid proper schema design; a relational model still outperforms jsonb for structured data with a known, stable shape.

Got a question or ran into a problem? Drop a comment below and I will reply.

PostgreSQL vs. SQL Server: What .NET Developers Need to Know Before Switching

You have shipped .NET APIs backed by SQL Server for years, and the next project lands with a PostgreSQL requirement. Or maybe your team is evaluating whether to drop SQL Server licence costs entirely. Either way, you are about to discover that "it is just another relational database" is only partially true.

Both SQL Server and PostgreSQL are mature, ACID-compliant databases with excellent EF Core support, but they make very different decisions around licensing, data types, and SQL dialect. This post unpacks those differences with real code so you can make the switch with confidence instead of surprises.

By the end you will have a side-by-side code comparison, a clear breakdown of the tradeoffs, and a decision framework you can use in your next architecture conversation.


1 A quick history first

SQL Server grew out of Microsoft's partnership with Sybase in the late 1980s and became the default relational database for Windows-based enterprise .NET applications. It is proprietary, commercially licensed, and deeply integrated with the Microsoft tooling ecosystem.

PostgreSQL descended from the POSTGRES project at UC Berkeley, went fully open-source in 1996, and has since grown into one of the most feature-rich databases in existence, with a reputation for standards compliance and extensibility.

Both use a cost-based query planner, support full ACID transactions, foreign keys, triggers, and stored procedures, and both have first-class EF Core providers that track releases closely.


2 The same task, two ways

To make the differences concrete, here is the same EF Core setup performed for each database: registering the context, applying a migration, and executing a simple parameterised query.

SQL Server approach

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default"),
        sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "dbo")
    ));
// AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .Property(p => p.Name)
            .HasMaxLength(256)
            .IsRequired();
    }
}

PostgreSQL approach

// Program.cs — only the provider registration changes
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(
        builder.Configuration.GetConnectionString("Default"),
        npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", "public")
    ));
// AppDbContext.cs — identical; EF Core abstracts the provider completely
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .Property(p => p.Name)
            .HasMaxLength(256)
            .IsRequired();
    }
}

The DbContext and entity configuration are identical. The provider swap is contained entirely in the Program.cs registration. The first gotcha surfaces in migrations: PostgreSQL folds unquoted identifiers to lowercase, so a column named Name becomes name on disk. The Npgsql provider handles this transparently in generated migrations, but any raw SQL you have written against SQL Server conventions will need a review pass before it works on PostgreSQL.


3 The real tradeoffs

1. Licensing and cost

SQL Server Standard edition starts at around $900 per core; Enterprise runs into the tens of thousands. Developer edition is free but cannot be used in production. PostgreSQL is completely free under the PostgreSQL Licence with no core count or server caps. For teams running many environments (dev, staging, pre-prod, multiple production regions), the licence difference is significant. Azure Database for PostgreSQL Flexible Server also prices lower than Azure SQL on equivalent compute tiers, though your specific configuration will vary.

Does this matter to you? If you are building SaaS and want to avoid per-core fee growth as you scale, PostgreSQL wins on economics. If your organisation already covers SQL Server under a Microsoft Enterprise Agreement, the marginal cost may already be absorbed.

2. Data types

PostgreSQL ships with native types that SQL Server lacks or emulates awkwardly: jsonb (binary JSON with index support), uuid (first-class UUID without the byte-order quirks of uniqueidentifier), typed arrays, range types, and the citext extension for case-insensitive text. SQL Server stores JSON as plain nvarchar(max) and queries it through scalar functions like JSON_VALUE and OPENJSON, without binary storage or index support on that data.

// EF Core 8: JSONB column via owned entity (PostgreSQL maps ToJson() to jsonb)
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ProductMetadata? Metadata { get; set; }
}

public class ProductMetadata
{
    public string? Sku { get; set; }
    public List<string> Tags { get; set; } = new();
}

// In OnModelCreating:
modelBuilder.Entity<Product>()
    .OwnsOne(p => p.Metadata, b => b.ToJson());
// Npgsql emits: "Metadata" jsonb
// SQL Server emits: "Metadata" nvarchar(max)

Same EF Core API; different storage reality. On PostgreSQL you can index into that jsonb column with a GIN index and run efficient containment queries. On SQL Server you are doing a full table scan through a function call.

3. SQL dialect differences

PostgreSQL is stricter about ANSI SQL compliance. Several T-SQL patterns break immediately: TOP becomes LIMIT, string concatenation with + must become || or CONCAT(), and GETDATE() becomes NOW() or CURRENT_TIMESTAMP. LINQ queries translated by EF Core are provider-aware and generally safe, but anything written with FromSqlRaw, Dapper, or stored procedures needs a dialect review. The case-sensitivity difference also catches teams off-guard: SQL Server string comparisons are case-insensitive by default (depending on collation); PostgreSQL is case-sensitive by default.

4. Tooling

SQL Server Management Studio is a mature, battle-tested GUI with profiler integration, visual execution plan analysis, and a large ecosystem of DBA tooling. PostgreSQL's first-party tool, pgAdmin 4, has improved significantly but still lags SSMS for complex query plan visualisation. Third-party tools such as DBeaver Community and JetBrains DataGrip fill that gap well. For .NET developers, the EF Core CLI (dotnet ef migrations add, dotnet ef database update) works identically across providers.

5. Extensions and ecosystem

PostgreSQL's extension system is one of its biggest differentiators. PostGIS adds production-grade geospatial support. TimescaleDB turns it into a time-series database. pg_trgm enables trigram similarity search. pgvector adds vector embeddings for AI workloads. These capabilities require separate licences or entirely different database products under SQL Server.


4 Which one should you choose?

The decision comes down to your licensing economics, the PostgreSQL-specific features your domain genuinely needs, and your team's operational familiarity.

Choose SQL Server when:

  • Your organisation covers SQL Server licences under a Microsoft EA and switching has no financial upside
  • Your team depends on SSMS, SQL Profiler, SQL Server Agent, or SSIS for operational workflows
  • You are integrating with SSRS, SSIS, or other SQL Server ecosystem components
  • Your team has deep T-SQL expertise and limited bandwidth to ramp on PostgreSQL dialect differences

Choose PostgreSQL when:

  • You want to eliminate per-core licence costs, particularly across many environments
  • Your domain benefits from native jsonb, arrays, range types, or extensions like PostGIS or pgvector
  • You are deploying to Linux containers, AWS RDS, or Azure Database for PostgreSQL Flexible Server
  • SQL portability and ANSI standards compliance matter to your architecture
  • You need full-text search, geospatial queries, or vector similarity without additional licences

You can also run both in the same solution. EF Core's per-context provider model makes it straightforward to keep an existing SQL Server context alongside a new PostgreSQL context for a service that needs JSONB or cost efficiency.

// Running two providers side by side in the same .NET 8 application
builder.Services.AddDbContext<LegacyDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Legacy")));

builder.Services.AddDbContext<CatalogDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));

5 Quick reference summary
Concern SQL Server PostgreSQL
LicenceCommercial (free Developer edition, not for prod)Open-source, free in all environments
EF Core NuGetMicrosoft.EntityFrameworkCore.SqlServerNpgsql.EntityFrameworkCore.PostgreSQL
Native JSON storagenvarchar(max) with scalar functionsjsonb with binary indexing
UUID typeuniqueidentifier (non-standard byte order)uuid (RFC 4122, native)
Case sensitivityCase-insensitive by default (collation-dependent)Case-sensitive by default
Full-text searchSeparate FT Catalog and Index, CONTAINS/FREETEXTBuilt-in via tsvector and tsquery
ArraysNot supported nativelyFirst-class typed array columns
ExtensionsLimited; additional features require licencesPostGIS, pgvector, TimescaleDB, pg_trgm, and more
Primary GUISQL Server Management StudiopgAdmin 4, DBeaver, DataGrip
Docker imagemcr.microsoft.com/mssql/serverpostgres (official, minimal, fast to pull)

Final take

If you are starting a new project today and do not have SQL Server licences already in place, PostgreSQL is the stronger default for .NET work in 2024. The Npgsql EF Core provider is excellent, the type system is richer, cloud-managed costs are lower, and the extension ecosystem opens doors that would require separate products under SQL Server.

If your team lives in SSMS, your SQL Server licences are paid for, and your domain has no need for JSONB or PostgreSQL-specific extensions, there is no compelling reason to switch for its own sake. The migration effort is real, and "it's free" is not a good enough reason on its own. Use PostgreSQL when the specific features or the economics give you a clear return on the investment.

What's next?
  • Spin up PostgreSQL locally: Run docker run --name pg-dev -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16 and point your first EF Core migration at it
  • Explore JSONB: The next post in this series covers JSONB columns in EF Core 8 with query examples, GIN indexing, and performance considerations
  • Audit your raw SQL: Before migrating an existing project, grep your codebase for FromSqlRaw and Dapper queries and flag any T-SQL-specific syntax for rewriting
Got a question or a scenario I did not cover? Drop a comment below and I will reply.