PostgreSQL  

Testing PostgreSQL 19 Upgrade Compatibility for .NET Applications

Upgrading a PostgreSQL database is not only a database administration task. For a .NET application, the database version is part of the application's runtime environment.

A PostgreSQL upgrade can affect:

  • SQL behavior

  • Query plans

  • Data types

  • Extensions

  • Indexes

  • EF Core mappings

  • Transactions

  • Connection handling

  • Authentication

  • Stored procedures

  • Migration scripts

  • Backup and restore

  • Application performance

An application that works correctly against the existing PostgreSQL version may still require changes when moved to a newer major version.

This is why upgrade compatibility testing should happen before production migration.

A useful validation model is:

Current PostgreSQL
        |
        v
Application Compatibility Tests
        |
        +--> Schema
        +--> Queries
        +--> EF Core
        +--> Transactions
        +--> Extensions
        +--> Migrations
        |
        v
PostgreSQL 19
        |
        v
Regression Testing
        |
        v
Production Readiness

This article explains how to design a repeatable PostgreSQL 19 upgrade compatibility test strategy for .NET applications.

Introduction

Consider a typical .NET application:

ASP.NET Core API
       |
       v
Entity Framework Core
       |
       v
Npgsql
       |
       v
PostgreSQL

The application may also depend on:

PostgreSQL
   |
   +-- JSONB
   +-- Full-text search
   +-- Extensions
   +-- Stored procedures
   +-- Views
   +-- Triggers
   +-- Custom types

An upgrade affects more than the database server itself.

The goal of compatibility testing is to answer several questions:

  1. Does the application start correctly?

  2. Can it connect to PostgreSQL 19?

  3. Does the existing schema work?

  4. Do EF Core queries still execute correctly?

  5. Are query results unchanged?

  6. Do migrations succeed?

  7. Do transactions behave correctly?

  8. Do extensions continue to work?

  9. Are performance regressions acceptable?

  10. Can the application recover from normal database failures?

The benchmark discussed in a separate performance-focused exercise answers how fast the new version is.

This article focuses on a different question:

Will the application continue to behave correctly after the PostgreSQL upgrade?

Why Major-Version Compatibility Testing Matters

A major PostgreSQL upgrade can introduce changes in behavior, planner decisions, supported features, or extension compatibility.

Even when PostgreSQL maintains strong SQL compatibility, applications can depend on details that are not obvious.

For example:

Application
   |
   +-- SQL query
   +-- EF Core mapping
   +-- Migration
   +-- Extension
   +-- Function
   +-- Type

Any of these can become an upgrade risk.

Inventory the Current Environment

Before testing the target version, document the current database environment.

Capture:

PostgreSQL Version
.NET Version
EF Core Version
Npgsql Version
Target Framework
Database Extensions
Schemas
Tables
Views
Functions
Triggers
Indexes
Custom Types
Collations
Authentication Configuration

A simple inventory might look like:

ComponentCurrent Value
PostgreSQLCurrent production version
.NETApplication runtime
EF CoreApplication ORM
NpgsqlPostgreSQL provider
DatabaseApplication database
ExtensionsApplication-specific
Migration SystemEF Core migrations

The exact versions should come from the production environment rather than assumptions.

Create an Upgrade Compatibility Matrix

Build a test matrix before changing the database.

AreaCurrent VersionPostgreSQL 19Result
ConnectionPassPending
AuthenticationPassPending
EF Core QueriesPassPending
MigrationsPassPending
TransactionsPassPending
JSONBPassPending
ExtensionsPassPending
Stored FunctionsPassPending
Backup/RestorePassPending
Integration TestsPassPending

This turns an upgrade into a controlled validation process.

Keep Application Variables Stable

During the first compatibility test, avoid changing everything simultaneously.

Keep:

.NET Version
EF Core Version
Npgsql Version
Application Code
Database Schema
Connection Pool
Configuration
Test Dataset

constant.

Change:

PostgreSQL Server

This gives you a clean baseline.

If a newer Npgsql version is also required for PostgreSQL 19 compatibility, test that as a separate variable where practical.

Build a Production-Like Test Database

Do not test only against an empty database.

Create a database that contains:

Production-like Schema
Production-like Data
Indexes
Views
Functions
Triggers
Extensions
Configuration

For sensitive systems, use sanitized or generated data.

The purpose is to reproduce application behavior, not to copy production data blindly.

Test Database Connectivity First

The first test should be simple.

await using var connection =
    new NpgsqlConnection(connectionString);

await connection.OpenAsync();

Console.WriteLine(connection.State);

If the application cannot establish a connection, there is little value in running higher-level tests.

Verify:

  • Host

  • Port

  • Database

  • Credentials

  • TLS requirements

  • Authentication method

  • Connection timeout

  • Connection pooling

Validate Npgsql Compatibility

The .NET PostgreSQL provider sits between the application and PostgreSQL.

.NET Application
       |
       v
Npgsql
       |
       v
PostgreSQL 19

Therefore, compatibility testing should explicitly include the provider.

Test:

  • Connection creation

  • Parameter binding

  • Transactions

  • Cancellation

  • Async operations

  • Data type mapping

  • Connection pooling

  • Error handling

Do not assume that application-level compatibility can be evaluated by testing PostgreSQL alone.

Test EF Core Migrations

Migration compatibility is one of the most important areas.

A typical workflow is:

Application Model
       |
       v
EF Core Migration
       |
       v
SQL
       |
       v
PostgreSQL 19

Test both:

Existing Database Upgrade

Start with a schema representing the current production database and apply the required migration path.

Fresh Database Creation

Create an empty PostgreSQL 19 database and apply all migrations from the beginning.

These are different tests.

An application can successfully upgrade an existing database while failing to build a fresh database because of a migration problem.

Migration Test Example

A CI environment can execute:

dotnet ef database update

against a disposable PostgreSQL 19 instance.

Then run application integration tests.

The migration pipeline should fail immediately if:

  • A migration cannot execute

  • A type is unsupported

  • An index cannot be created

  • A function is missing

  • An extension is unavailable

Test Rollback Behavior

Upgrade compatibility is not only about successful migrations.

Test failure scenarios.

For example:

Migration
   |
   +--> Step 1
   +--> Step 2
   +--> Step 3
          |
          X Failure

Verify the database remains in a known state.

For production migration planning, also document whether the migration is reversible and what the rollback strategy actually means.

A PostgreSQL major-version upgrade itself should not be treated as equivalent to simply rolling back an application deployment.

Validate Data Types

Applications often rely on PostgreSQL-specific data types.

Examples include:

  • jsonb

  • uuid

  • timestamp

  • timestamptz

  • numeric

  • Arrays

  • Range types

  • Network types

  • Full-text search types

Create explicit tests for important mappings.

For example:

public sealed class Order
{
    public Guid Id { get; set; }

    public DateTime CreatedAt { get; set; }

    public decimal Total { get; set; }
}

The test should verify not only that the record can be inserted but also that it can be retrieved without unexpected changes.

Test Round-Trip Data Integrity

A useful compatibility test is:

.NET Value
    |
    v
PostgreSQL
    |
    v
.NET Value

For each important type:

  1. Create a representative value.

  2. Save it.

  3. Read it back.

  4. Compare the original and returned values.

For example:

Assert.Equal(original.Id, loaded.Id);
Assert.Equal(original.Total, loaded.Total);
Assert.Equal(original.CreatedAt, loaded.CreatedAt);

Include boundary values where appropriate.

Date and Time Testing

Date and time handling deserves special attention.

Test:

  • UTC timestamps

  • Offset values

  • Date-only values

  • Time-only values

  • Minimum supported dates

  • Maximum expected dates

  • Daylight-saving-related application behavior where relevant

The goal is to verify the application's semantic expectations, not just whether a column can be created.

JSONB Compatibility

If the application uses JSONB, test:

Insert
Read
Update
Filter
Containment
Extraction
Serialization
Deserialization

Example:

var customer = await db.Customers
    .Where(c => c.Metadata.Region == "EU")
    .FirstOrDefaultAsync();

The test should verify:

  • Query executes

  • Generated SQL remains valid

  • Result is correct

  • Expected indexes are usable

  • Serialization remains consistent

Test JSON Serialization

EF Core and Npgsql may map JSON data into .NET types.

For example:

public sealed class CustomerMetadata
{
    public string Region { get; set; } = string.Empty;

    public bool NotificationsEnabled { get; set; }
}

Test:

.NET Object
    |
    v
JSONB
    |
    v
.NET Object

Include null values, missing properties, arrays, nested objects, and representative production data.

Test Raw SQL

Many applications use a combination of EF Core LINQ and raw SQL.

Search the codebase for:

FromSql
ExecuteSql
ExecuteSqlRaw
CommandText
NpgsqlCommand
Stored Procedure Calls

Every important raw SQL path should be part of the compatibility suite.

For example:

var orders = await db.Orders
    .FromSqlRaw("""
        SELECT *
        FROM orders
        WHERE status = 'Open'
        """)
    .ToListAsync();

The important part is not the example itself but ensuring that raw SQL used by the real application is covered.

Test Stored Functions and Procedures

Some applications rely heavily on database-side logic:

Application
    |
    v
Stored Function
    |
    v
Tables

Test:

  • Function execution

  • Input parameters

  • Output values

  • Error handling

  • Transaction behavior

  • Application mapping

Do not assume that an extension-heavy or function-heavy application has the same compatibility profile as a simple CRUD application.

Validate Extensions

PostgreSQL extensions should be explicitly inventoried.

Examples might include functionality for:

  • UUID generation

  • Full-text search

  • Spatial data

  • Cryptography

  • Specialized indexing

For every extension:

Extension
   |
   +-- Installed?
   +-- Correct Version?
   +-- Required by Schema?
   +-- Required by Application?
   +-- Required by Migration?

A PostgreSQL server upgrade can be successful while the application still fails because a required extension is unavailable or incompatible.

Test Indexes

The schema may contain specialized indexes:

CREATE INDEX ix_orders_customer_id
ON orders(customer_id);

JSONB indexes may look like:

CREATE INDEX ix_customers_metadata
ON customers
USING GIN(metadata);

Validate:

  • Index creation

  • Index existence

  • Index validity

  • Query behavior

  • Migration scripts

  • Important execution plans

Do not assume that an index surviving the upgrade means all queries will use it in exactly the same way.

Query Correctness Comes Before Query Speed

A compatibility test should first ask:

Does the query return the correct result?

Only after that should you ask:

Is it fast enough?

For example:

Version A -> 1,250 rows
Version B -> 1,250 rows

is more important initially than:

Version A -> 30 ms
Version B -> 28 ms

If the row count or result content changes unexpectedly, investigate correctness first.

Build Query Regression Tests

For important queries, compare:

Expected Result
      |
      v
PostgreSQL Current
      |
      v
PostgreSQL 19

The same fixture data should be used for both environments.

Test:

  • Row count

  • IDs

  • Aggregates

  • Ordering

  • Null behavior

  • Date calculations

  • JSONB results

  • Pagination

Test Transaction Semantics

Transactions are central to many business applications.

Example:

await using var transaction =
    await db.Database.BeginTransactionAsync();

try
{
    // Business operations

    await db.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Test:

  • Commit

  • Rollback

  • Nested application operations

  • Constraint failures

  • Concurrent updates

  • Deadlock handling

  • Cancellation

Test Concurrency

A database upgrade can expose issues that do not appear in single-user tests.

Run concurrent operations such as:

100 simultaneous requests
       |
       +-- Reads
       +-- Inserts
       +-- Updates
       +-- Transactions

Look for:

  • Deadlocks

  • Lock waits

  • Serialization failures

  • Connection pool exhaustion

  • Unexpected transaction failures

Test Connection Pooling

Connection pooling should be tested under realistic load.

For example:

Request
   |
   v
Connection Pool
   |
   +-- Available Connection
   |
   +-- Wait

Monitor:

  • Connection acquisition latency

  • Active connections

  • Idle connections

  • Pool exhaustion

  • Connection failures

A database upgrade should not unexpectedly destabilize connection management.

Test Failure and Recovery

A production database is not always healthy.

Test scenarios such as:

Database unavailable
Connection reset
Query timeout
Transaction failure
Connection pool exhaustion

Verify that the application:

  • Returns appropriate errors

  • Does not corrupt application state

  • Releases connections

  • Retries only when appropriate

  • Recovers after the database becomes available

Be careful with retries around transactions because replaying a non-idempotent operation can create duplicate effects.

Test Cancellation and Timeouts

For async .NET applications, database cancellation matters.

Example:

await db.Customers
    .Where(c => c.Status == "Active")
    .ToListAsync(cancellationToken);

Verify that cancellation actually propagates through the provider and database operation.

Also test command timeout behavior.

Validate Authentication

The application may rely on:

  • Password authentication

  • TLS

  • Certificate validation

  • Environment-provided credentials

  • Secret management

  • Connection-string configuration

Test the exact authentication configuration used by the application.

A database upgrade should not be considered compatible if the application cannot authenticate using its production security model.

Test TLS

If encrypted connections are required, validate:

Application
   |
   | TLS
   v
PostgreSQL 19

Verify:

  • Certificate validation

  • Trust chain

  • Hostname verification

  • Encryption requirements

  • Connection failures for invalid certificates

Test Backup and Restore

Upgrade compatibility includes operational recovery.

Create a backup from the source environment and validate the planned upgrade and restore strategy.

Test:

Backup
   |
   v
Upgrade Environment
   |
   v
Restore / Recovery
   |
   v
Application Tests

The exact backup strategy depends on the chosen PostgreSQL upgrade mechanism.

The important requirement is to test the actual recovery procedure before production migration.

Validate Schema Drift

Compare the expected schema with the upgraded database.

Check:

Tables
Columns
Types
Constraints
Indexes
Views
Functions
Triggers
Extensions

Schema comparison is particularly useful after automated migrations.

Test Application Startup

The application's startup path can expose compatibility issues immediately.

Run:

Start Application
      |
      v
Load Configuration
      |
      v
Create DbContext
      |
      v
Connect
      |
      v
Health Checks
      |
      v
Ready

A production-style health check should validate the database connection.

For example:

builder.Services.AddHealthChecks()
    .AddNpgSql(connectionString);

Use the health-check mechanism appropriate to the application's architecture and dependency versions.

Run Full Integration Tests

Unit tests cannot validate database compatibility.

A useful test pyramid is:

             End-to-End
                 /\
                /  \
         Integration Tests
              /      \
             /        \
        Database Tests
           /            \
          /              \
       Unit Tests

For a PostgreSQL upgrade, integration tests become especially important because they exercise:

Application
   +
EF Core
   +
Npgsql
   +
PostgreSQL

Build a Disposable PostgreSQL 19 Environment

A practical CI approach is to create an isolated PostgreSQL instance for every compatibility run.

Conceptually:

CI Job
 |
 +-- Start PostgreSQL 19
 |
 +-- Create Database
 |
 +-- Apply Migrations
 |
 +-- Seed Test Data
 |
 +-- Run Integration Tests
 |
 +-- Collect Logs
 |
 +-- Destroy Environment

Containerized environments are particularly useful because the database version can be controlled explicitly.

Use the Same Dataset

Compatibility tests should use deterministic data.

For example:

Seed
 |
 +-- Customers
 +-- Orders
 +-- Products
 +-- Payments
 +-- Metadata

Use a fixed seed where randomized data is involved.

This makes failures reproducible.

Validate Performance as a Secondary Gate

Once correctness is established, add performance checks.

Track:

p50
p95
p99
Throughput
CPU
Memory
Database Connections

Set reasonable regression thresholds.

For example:

Critical Query
Maximum p95 Regression: 10%

The threshold should be based on application requirements rather than an arbitrary universal value.

Detect Query Plan Changes

Store important query plans before and after the upgrade.

For example:

Before:
Index Scan

After:
Sequential Scan

A plan change is not automatically a defect.

The important question is whether it produces unacceptable behavior.

If performance regresses, the plan gives you a starting point for investigation.

Test Long-Running Queries

Short CRUD queries are not enough.

Include:

  • Reports

  • Aggregations

  • Large joins

  • Search queries

  • JSONB queries

  • Batch processing

  • Background jobs

Long-running queries are more likely to expose planner or resource-management differences.

Test Background Workers

Many .NET applications have hosted services:

public sealed class OrderWorker : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Process database work

            await Task.Delay(
                TimeSpan.FromSeconds(10),
                stoppingToken);
        }
    }
}

These workloads should be included because they may behave differently from HTTP requests.

Test:

  • Startup

  • Shutdown

  • Database reconnect

  • Long-running transactions

  • Batch processing

  • Retry behavior

Test Reporting and Analytics Workloads

Applications often have a second database usage pattern:

Transactional Queries
+
Reporting Queries

A PostgreSQL upgrade may behave differently under analytical workloads.

Include representative:

GROUP BY
JOIN
ORDER BY
Window Functions
Aggregations
JSONB Extraction

queries.

Check Observability

During compatibility tests, collect:

Application Logs
Database Logs
Query Duration
Connection Errors
Exceptions
Lock Waits
CPU
Memory
I/O

Without observability, a failed test may tell you only:

Test failed

With observability, you can determine:

Test failed
      |
      v
Connection error
      |
      v
Authentication configuration

or:

Test failed
      |
      v
Query timeout
      |
      v
Execution plan changed

Establish Clear Exit Criteria

An upgrade should have measurable acceptance criteria.

For example:

Compatibility Gate

[PASS] Application starts
[PASS] Database connection
[PASS] All migrations
[PASS] Critical queries
[PASS] Data integrity
[PASS] Extensions
[PASS] Integration tests
[PASS] Backup/recovery test
[PASS] Performance threshold

The exact criteria should be specific to the application.

Common Upgrade Compatibility Failures

Extension Not Available

The application depends on a PostgreSQL extension that has not been installed or validated in the target environment.

Migration Failure

A migration assumes a database behavior that does not work in the upgraded environment.

Provider Compatibility

The application uses an Npgsql version that has not been validated against the target database version.

Query Result Difference

A query still executes but produces unexpected results.

Query Plan Regression

The query remains correct but becomes significantly slower.

Authentication Failure

The database is running, but the application's authentication configuration no longer works.

Time Zone Problems

Date/time assumptions become visible during integration testing.

Custom Type Mapping Issues

The application uses PostgreSQL-specific types that are not correctly mapped in the application layer.

Function or Procedure Failure

Database-side business logic does not behave as expected.

Connection Pool Problems

The application experiences connection failures or pool exhaustion under load.

Advantages of Automated Upgrade Testing

Repeatability

The same test suite can be executed against multiple PostgreSQL versions.

Early Detection

Compatibility issues are discovered before production migration.

Better Upgrade Confidence

The decision is based on application behavior rather than assumptions.

CI/CD Integration

Upgrade testing can become a repeatable engineering process.

Faster Rollout

Once the test suite is mature, future PostgreSQL upgrades require less manual validation.

Disadvantages

Initial Setup Cost

A realistic compatibility suite requires time to build.

Test Maintenance

Database behavior, schema, and application features evolve.

Environment Differences

A CI database may not perfectly represent production infrastructure.

Extension Complexity

Some extensions require additional compatibility validation.

Operational Testing Is Harder

Backup, recovery, failover, and infrastructure behavior cannot always be completely simulated in a local environment.

Best Practices

  1. Inventory the entire PostgreSQL environment before upgrading.

  2. Keep application variables stable during the first compatibility test.

  3. Test the exact .NET, EF Core, and Npgsql combination used by the application.

  4. Use a production-like schema and representative data.

  5. Test both existing-database upgrades and fresh database creation.

  6. Validate all important PostgreSQL-specific data types.

  7. Test JSONB serialization, querying, and updates.

  8. Validate raw SQL and database-side functions.

  9. Inventory and test required extensions.

  10. Verify indexes and important execution plans.

  11. Test transaction and concurrency behavior.

  12. Test connection pooling and authentication.

  13. Validate TLS configuration where applicable.

  14. Test failure recovery and timeout behavior.

  15. Run full integration tests against PostgreSQL 19.

  16. Include background jobs and reporting workloads.

  17. Capture application and database observability data.

  18. Establish explicit performance regression thresholds.

  19. Test backup and recovery procedures.

  20. Document clear production-readiness exit criteria.

Frequently Asked Questions

Do I need to upgrade Npgsql at the same time?

Not necessarily. The correct Npgsql version depends on the application's existing stack and compatibility requirements. If an upgrade is required, treat it as an explicit variable in the test plan rather than changing it silently.

Is running EF Core migrations enough to validate compatibility?

No. Migrations validate only part of the system. You should also test queries, data types, transactions, extensions, authentication, background jobs, and application behavior.

Should I use production data?

Use production-like data, but follow your organization's security and privacy requirements. Sanitized or generated datasets are often more appropriate for automated testing.

Should compatibility tests include performance?

Yes, but correctness should be evaluated first. Performance regression testing is a separate but important layer of upgrade validation.

What if all integration tests pass but performance gets worse?

Investigate query plans, indexes, statistics, connection behavior, and resource utilization. A successful functional test suite does not guarantee equivalent performance.

Should I test PostgreSQL extensions separately?

Yes. Extensions should be treated as explicit compatibility dependencies.

How should I test an existing production database?

Create a representative copy or sanitized test environment and execute the planned upgrade procedure against it. This is more valuable than testing only an empty database.

Can I automatically roll back a PostgreSQL major upgrade?

Do not assume that a major-version upgrade has a simple database rollback equivalent. The rollback strategy depends on the upgrade method, backup strategy, replication architecture, and operational design. Validate the actual recovery procedure before production migration.

Conclusion

Testing PostgreSQL 19 compatibility for a .NET application should be treated as an application upgrade project, not simply a database-version change.

The complete system looks like:

.NET Application
       |
       v
EF Core
       |
       v
Npgsql
       |
       v
PostgreSQL 19
       |
       +-- Schema
       +-- Queries
       +-- Extensions
       +-- Transactions
       +-- Indexes
       +-- Storage

A reliable compatibility strategy validates each layer.

Start with connectivity and schema compatibility, then move through EF Core queries, data types, migrations, extensions, transactions, concurrency, authentication, background workloads, and performance. Finally, validate backup and recovery procedures and define explicit production-readiness criteria.

The most important principle is simple: do not decide whether a PostgreSQL major upgrade is safe based only on whether the server starts or migrations complete.

A successful upgrade means the application continues to produce correct results, maintain data integrity, handle expected workloads, and meet its operational requirements on the new PostgreSQL version.