.NET Programming With Me

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.