PostgreSQL  

Benchmarking PostgreSQL 18 vs 19 Beta for Write-Heavy .NET APIs

Write-heavy APIs put very different demands on a database than read-heavy applications. A system that mostly serves cached GET requests can hide database limitations for a long time. An API that continuously inserts orders, updates inventory, records events, writes audit data, or processes background jobs cannot hide them as easily.

For .NET applications using PostgreSQL, a major-version upgrade is therefore worth testing from a write-path perspective before production rollout.

PostgreSQL 19 is especially interesting because the release includes improvements that can affect write-heavy workloads, including changes to storage, indexing, I/O, WAL-related behavior, and other internal database operations. However, PostgreSQL 19 Beta should be treated as a test target rather than a production deployment. The goal of a benchmark is not to prove that PostgreSQL 19 is faster. It is to determine where the new version changes the behavior of your particular workload.

This article walks through a practical PostgreSQL 18 versus PostgreSQL 19 beta benchmark for a write-heavy .NET API, with emphasis on repeatability, realistic workloads, transaction behavior, connection pooling, WAL generation, latency, throughput, and failure scenarios.

Why Write-Heavy PostgreSQL Benchmarks Are Different

A write request can involve considerably more work than the application code suggests.

Consider a simple API endpoint:

[HttpPost]
public async Task<IActionResult> CreateOrder(
    CreateOrderRequest request,
    CancellationToken cancellationToken)
{
    var order = new Order
    {
        CustomerId = request.CustomerId,
        TotalAmount = request.TotalAmount,
        CreatedAt = DateTime.UtcNow
    };

    db.Orders.Add(order);

    await db.SaveChangesAsync(cancellationToken);

    return Ok(order.Id);
}

The application sees one SaveChangesAsync() call.

The database may perform several operations:

.NET API
   |
   v
EF Core
   |
   v
Npgsql
   |
   v
PostgreSQL
   |
   +--> Table modification
   +--> Index maintenance
   +--> WAL generation
   +--> Transaction handling
   +--> Buffer management
   +--> Storage I/O

If the table has six indexes, the cost of inserting one row is not simply the cost of writing one row.

That is why a PostgreSQL version benchmark should measure the entire write path rather than only the SQL statement execution time.

Define the Benchmark Before Running It

The first rule is to define what you are measuring.

A useful benchmark should answer questions such as:

  • Can PostgreSQL 19 process more writes per second?

  • Does p95 or p99 latency improve?

  • Does transaction latency change under concurrency?

  • Does WAL generation change?

  • Does index maintenance become more expensive or less expensive?

  • Does CPU become the bottleneck?

  • Does storage become the bottleneck?

  • Does connection pooling affect the result?

  • Does EF Core add a meaningful amount of overhead?

  • Does performance remain stable as concurrency increases?

Without these questions, it is easy to collect numbers without learning anything useful.

Build a Production-Style Write Workload

Avoid benchmarking a single table with one integer column.

A realistic API should have relationships, indexes, constraints, and several types of writes.

For example:

CREATE TABLE orders
(
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    status VARCHAR(30) NOT NULL,
    total_amount NUMERIC(12,2) NOT NULL,
    metadata JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Add realistic indexes:

CREATE INDEX ix_orders_customer_id
ON orders(customer_id);

CREATE INDEX ix_orders_status
ON orders(status);

CREATE INDEX ix_orders_created_at
ON orders(created_at);

CREATE INDEX ix_orders_metadata
ON orders USING GIN(metadata);

This is much closer to a real application than a table containing only an ID and timestamp.

At the same time, do not create unnecessary indexes merely to make the benchmark complicated. Every index changes write behavior, so indexes should represent what the production application actually needs.

Include Multiple Write Patterns

A useful benchmark should not depend on one INSERT statement.

For example, an e-commerce-style workload could contain:

OperationApproximate Share
Create order35%
Update order status25%
Insert payment event15%
Update customer data10%
Insert audit record10%
Other writes5%

The exact distribution should come from production telemetry when available.

If the application is an event ingestion service, the distribution will look completely different.

The benchmark should reproduce the business workload rather than forcing every application into the same pattern.

Use EF Core for the Application Benchmark

A database-only benchmark is useful, but a .NET API benchmark should also include the real application stack.

For example:

public async Task CreateOrderAsync(
    CreateOrderRequest request,
    CancellationToken cancellationToken)
{
    await using var transaction =
        await db.Database.BeginTransactionAsync(cancellationToken);

    var order = new Order
    {
        CustomerId = request.CustomerId,
        Status = "Pending",
        TotalAmount = request.TotalAmount,
        Metadata = request.Metadata,
        CreatedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow
    };

    db.Orders.Add(order);

    await db.SaveChangesAsync(cancellationToken);

    db.OrderEvents.Add(new OrderEvent
    {
        OrderId = order.Id,
        EventType = "Created",
        CreatedAt = DateTime.UtcNow
    });

    await db.SaveChangesAsync(cancellationToken);

    await transaction.CommitAsync(cancellationToken);
}

This introduces a more realistic transactional workload.

However, the benchmark should also test whether two separate SaveChangesAsync() calls are actually required. If they are not, combining operations can reduce database round trips.

The benchmark should measure the application as it exists, not silently optimize it during the comparison.

Keep the Test Environment Identical

The PostgreSQL 18 and PostgreSQL 19 environments should be as similar as possible.

Keep these variables constant:

VariablePostgreSQL 18PostgreSQL 19
CPUSameSame
RAMSameSame
StorageSameSame
OSSameSame
.NET runtimeSameSame
EF CoreSameSame
NpgsqlSameSame
API buildSameSame
DatasetSameSame
IndexesSameSame
Connection poolSameSame
WorkloadSameSame

Only the PostgreSQL version should change.

Otherwise, you are measuring multiple variables simultaneously.

Benchmark Single-Row Inserts First

Start with the simplest workload.

For example:

INSERT INTO orders
(
    customer_id,
    status,
    total_amount,
    metadata
)
VALUES
(
    10001,
    'Pending',
    249.99,
    '{"source":"web","priority":"normal"}'
);

Measure latency at low concurrency first.

This establishes a baseline.

Then increase concurrency gradually:

1 client
5 clients
10 clients
25 clients
50 clients
100 clients
250 clients

Do not assume that more concurrency always means more throughput.

At some point, another resource becomes saturated.

Benchmark Batched Inserts

Many .NET applications process data in batches.

For example:

foreach (var item in batch)
{
    db.Events.Add(item);
}

await db.SaveChangesAsync(cancellationToken);

Compare different batch sizes:

Batch SizePurpose
1Single-row workload
10Small batch
50Medium batch
100Common API/background-job batch
500Large batch
1,000Stress test

The objective is to find where throughput improves and where latency or memory consumption starts increasing.

A batch of 1,000 may deliver higher throughput but create worse tail latency than a batch of 100.

Measure Transaction Latency

Transactions should be measured separately from raw SQL execution.

For example:

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

await ProcessOrderAsync(order, cancellationToken);

await transaction.CommitAsync(cancellationToken);

Measure:

Transaction start
       |
       v
Database operations
       |
       v
Commit
       |
       v
Response

The commit portion matters because a successful API response may depend on the transaction being durably committed.

A benchmark that measures only the INSERT statement can miss this cost.

Measure WAL Generation

Write-heavy PostgreSQL workloads generate WAL, or Write-Ahead Logging.

WAL allows PostgreSQL to maintain durability and supports recovery and replication.

When comparing versions, track WAL behavior where possible.

Useful measurements include:

  • WAL bytes generated.

  • WAL records.

  • WAL-related I/O.

  • Transaction rate.

  • Checkpoint behavior.

  • Replication lag in replicated environments.

The objective is not simply to minimize WAL. WAL is fundamental to PostgreSQL durability.

Instead, ask whether the amount and behavior of WAL are appropriate for the workload and whether the PostgreSQL version changes the cost of producing or processing it.

Test Index-Heavy Tables

Indexes are one of the most important variables in write benchmarks.

Consider a table with only the primary key:

CREATE TABLE events
(
    id BIGSERIAL PRIMARY KEY,
    payload JSONB,
    created_at TIMESTAMPTZ NOT NULL
);

Now compare it with a production-style version containing several indexes.

Every INSERT now has additional index-maintenance work.

For example:

CREATE INDEX ix_events_created_at
ON events(created_at);

CREATE INDEX ix_events_payload
ON events USING GIN(payload);

If PostgreSQL 19 performs differently under this configuration, the difference may become much more visible than in a simple primary-key-only test.

This is why a production benchmark should preserve real index structures.

Include JSONB Writes

If your .NET API stores JSONB metadata, test it separately.

For example:

UPDATE orders
SET metadata = jsonb_set(
    metadata,
    '{processingStatus}',
    '"completed"'::jsonb
),
updated_at = now()
WHERE id = 100001;

Do not assume that changing a small JSON property means PostgreSQL writes only that tiny property.

The physical behavior of JSONB updates can be more expensive than the SQL statement suggests, particularly for large documents.

Benchmark:

Small JSONB document
Medium JSONB document
Large JSONB document

This is especially important when PostgreSQL 19's storage-related changes are part of the upgrade evaluation.

Test Read-Modify-Write Workloads

Real APIs frequently read a row and then update it.

For example:

var order = await db.Orders
    .SingleAsync(x => x.Id == orderId, cancellationToken);

order.Status = "Completed";
order.UpdatedAt = DateTime.UtcNow;

await db.SaveChangesAsync(cancellationToken);

Under concurrency, this can expose locking and contention behavior.

A stronger benchmark should run multiple workers against overlapping records.

For example:

Worker 1 -> Order 100
Worker 2 -> Order 100
Worker 3 -> Order 101
Worker 4 -> Order 102

This is much closer to real production behavior than having every worker update a completely different row.

Measure Connection Pool Behavior

The PostgreSQL benchmark can be misleading if the client connection pool becomes the bottleneck.

For .NET applications using Npgsql, test controlled pool sizes.

For example:

Minimum Pool Size: 10
Maximum Pool Size: 50

Then compare with:

Minimum Pool Size: 20
Maximum Pool Size: 100

The exact settings should reflect the application.

If throughput stops increasing while PostgreSQL still has available CPU and I/O capacity, inspect the application-side connection pool before concluding that PostgreSQL is the bottleneck.

Measure Latency Percentiles

Average latency is not enough for a write-heavy API.

Suppose two tests produce:

VersionAveragep95p99
PostgreSQL 1812 ms20 ms45 ms
PostgreSQL 1911 ms19 ms90 ms

The average suggests an improvement.

The p99 tells a different story.

This is why the benchmark should report at least:

  • Median.

  • p95.

  • p99.

  • Maximum where useful.

  • Requests per second.

  • Error rate.

Tail latency becomes especially important when the database is under contention.

Test Sustained Workloads

A five-minute benchmark can miss behavior that appears after sustained activity.

Run longer tests when possible.

For example:

Warm-up:      5 minutes
Measurement:  30 minutes
Cool-down:    5 minutes

During the measurement period, monitor:

CPU
Memory
Disk I/O
Database connections
WAL
Checkpoints
Locks
Transaction rate
API latency
Error rate

The objective is to detect performance degradation over time.

Test Checkpoint and Storage Pressure

Write-heavy applications continuously generate dirty pages and WAL.

As the workload grows, checkpoint and storage behavior can become significant.

A benchmark should therefore include a stress scenario where the database operates near the expected production write rate.

Do not artificially make the database faster by giving it substantially more storage performance than production.

Likewise, do not benchmark on a developer laptop and use those numbers as production capacity estimates.

Use EXPLAIN for Representative Queries

Although this article focuses on writes, the API will usually contain read-before-write operations.

Inspect those queries with:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE customer_id = 10001
ORDER BY created_at DESC
LIMIT 20;

Look for:

  • Sequential scans.

  • Index scans.

  • Bitmap scans.

  • Rows removed by filters.

  • Shared buffer reads.

  • Shared buffer hits.

  • Sort operations.

  • Estimated versus actual rows.

A slow read inside a transaction can reduce write throughput even if the INSERT itself is fast.

Test Failure and Rollback Behavior

Production workloads are not made entirely of successful transactions.

Include failures such as:

Constraint violation
Duplicate key
Deadlock
Serialization conflict
Application cancellation
Database connection interruption
Transaction timeout

For example:

try
{
    await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
    // Record failure and return an appropriate response.
}

Measure both successful and failed transaction behavior.

A version upgrade should not be evaluated solely on successful writes.

Common Benchmark Mistakes

Changing Multiple Variables

If PostgreSQL 19 also uses a different Npgsql version, a different machine, and different indexes, the benchmark cannot isolate the PostgreSQL upgrade.

Measuring Only Throughput

Higher throughput is useful only if latency and error rates remain acceptable.

Ignoring Tail Latency

A database that improves average latency but produces much worse p99 latency may not be an upgrade for a latency-sensitive API.

Using Only Empty Tables

A database with 10,000 rows behaves differently from one with hundreds of millions of rows and realistic index structures.

Ignoring WAL

Write throughput should be evaluated alongside durability-related database activity.

Benchmarking Only INSERT

Real systems perform updates, deletes, transactional operations, and read-modify-write workflows.

Using Different Dataset Distributions

The same number of rows is not enough. The data distribution must also be comparable.

A Practical Benchmark Matrix

A useful first-pass matrix could look like this:

TestPostgreSQL 18PostgreSQL 19 Beta
Single-row INSERTMeasureMeasure
Batch INSERTMeasureMeasure
UPDATEMeasureMeasure
JSONB UPDATEMeasureMeasure
Transaction workloadMeasureMeasure
Read-modify-writeMeasureMeasure
High concurrencyMeasureMeasure
Index-heavy writesMeasureMeasure
Sustained writesMeasureMeasure
Failure/rollback workloadMeasureMeasure

For each test, record:

MetricWhy It Matters
ThroughputOverall write capacity
Median latencyTypical request behavior
p95Tail behavior
p99Worst common latency
Error rateReliability
WAL generationWrite amplification/durability workload
CPUCompute bottleneck
Disk I/OStorage bottleneck
ConnectionsPool/database pressure
LocksContention
Table/index sizeStorage impact

How to Interpret the Results

Suppose PostgreSQL 19 shows higher throughput at 10 concurrent workers but lower throughput at 250 workers.

That does not mean the benchmark failed.

It tells you that the performance characteristics changed as concurrency increased.

Similarly, if PostgreSQL 19 reduces median latency but increases p99 latency, investigate contention, I/O, locking, checkpoint behavior, and connection utilization before declaring the result positive or negative.

The purpose of a benchmark is to discover these boundaries.

A useful final report should therefore say something like:

PostgreSQL 19 improved throughput for the tested write workload
under moderate concurrency, while high-concurrency tests became
limited by storage and connection pressure.

JSONB-heavy updates showed different behavior from simple inserts,
so the upgrade decision should consider the application's actual
write distribution.

That is much more useful than saying:

PostgreSQL 19 is 15% faster.

Troubleshooting Unexpected Results

If PostgreSQL 19 performs worse, first confirm that the environment is genuinely equivalent.

Check:

PostgreSQL configuration
CPU allocation
Memory allocation
Storage device
Filesystem
Dataset
Indexes
Statistics
Connection pool
.NET runtime
EF Core version
Npgsql version
Workload distribution

Then inspect database-level metrics.

A performance regression can come from a changed execution plan, increased contention, storage behavior, client-side pooling, or simply a benchmark that is no longer testing the same workload.

If the result is reproducible, reduce the workload to a smaller test case. Isolate the specific SQL statement or transaction pattern responsible for the difference.

This makes it easier to determine whether the issue belongs to PostgreSQL, the application, or the benchmark itself.

Frequently Asked Questions

Is PostgreSQL 19 faster than PostgreSQL 18 for .NET APIs?

There is no universal answer. Performance depends on the workload, database configuration, hardware, query patterns, indexes, concurrency, and application behavior.

Should I benchmark PostgreSQL directly or through EF Core?

Do both. SQL-level benchmarks help isolate PostgreSQL behavior, while end-to-end .NET benchmarks show the performance experienced by the API.

What should I measure besides requests per second?

At minimum, measure median latency, p95, p99, error rate, CPU, disk I/O, database connections, and WAL activity.

Why are p95 and p99 important?

Average latency can hide a small number of very slow requests. Tail latency often determines whether an API remains responsive under load.

Should the benchmark include JSONB?

If the application uses JSONB, absolutely. JSONB writes, indexes, and large documents can materially affect write performance.

Should I test PostgreSQL 19 Beta in production?

No. Beta releases should be used for controlled testing and compatibility evaluation, not normal production deployment.

How long should a write benchmark run?

There is no universal duration, but short tests should be supplemented with sustained workloads long enough to expose cache, checkpoint, storage, connection, and contention behavior.

Conclusion

Benchmarking PostgreSQL 18 versus PostgreSQL 19 for a write-heavy .NET API is more useful when the test represents the application instead of a synthetic INSERT loop. A realistic benchmark should include EF Core, Npgsql, actual table structures, production-style indexes, representative transaction boundaries, JSONB where applicable, realistic concurrency, connection pooling, and sustained write traffic.

The most important measurements are not limited to throughput. Median latency, p95 and p99 latency, WAL generation, CPU, storage I/O, locking, connection utilization, and error rates help explain how the database behaves as pressure increases.

PostgreSQL 19 should therefore be evaluated as a workload-specific upgrade. Run the same application build against PostgreSQL 18 and PostgreSQL 19 Beta, keep the environment controlled, collect the same metrics, and investigate differences at the query-plan and database-resource level. The resulting benchmark will give a much more reliable answer about whether PostgreSQL 19 is ready for your .NET application's production migration than a generic version comparison ever could.