PostgreSQL  

PostgreSQL 19 Beta: Benchmarking JSONB Workloads from .NET APIs

Introduction

JSON is everywhere in modern .NET applications.

APIs frequently accept flexible request payloads, store metadata, integrate with third-party systems, and persist event data that does not fit neatly into relational columns. PostgreSQL's jsonb type makes this practical by allowing applications to store structured JSON while still providing indexing and query capabilities.

For an EF Core application, however, using jsonb effectively requires more than simply changing a column type.

The performance of JSONB workloads depends on:

  • Document size

  • Query patterns

  • JSON path complexity

  • Index design

  • Data distribution

  • Number of rows

  • Update frequency

  • Projection strategy

  • Serialization overhead

  • EF Core query translation

This becomes particularly important when evaluating a PostgreSQL version upgrade.

A query that works correctly against an older database may behave differently when the database engine, statistics, planner decisions, or workload characteristics change.

A useful benchmark therefore needs to measure the complete path:

.NET API
   |
   v
EF Core
   |
   v
SQL
   |
   v
PostgreSQL JSONB
   |
   v
Index / Query Plan
   |
   v
Result

This article explains how to benchmark JSONB workloads from .NET APIs and identify performance regressions before they reach production.

What Is JSONB?

PostgreSQL provides jsonb as a binary representation of JSON data.

Consider a document like:

{
  "customerId": 1842,
  "region": "north",
  "preferences": {
    "language": "en",
    "notifications": true
  },
  "tags": [
    "premium",
    "enterprise"
  ]
}

A relational model could store these values in separate columns.

But some application data is naturally variable.

For example:

Customer
    |
    +---- Fixed columns
    |       Id
    |       Name
    |       CreatedAt
    |
    +---- JSONB
            Preferences
            Metadata
            Attributes

This hybrid model can be useful when some fields are stable while other attributes change frequently.

Why JSONB Performance Needs Benchmarking

A JSONB column can make application development easier, but flexible storage does not automatically mean efficient querying.

For example:

SELECT *
FROM customers
WHERE metadata->>'region' = 'north';

This may behave very differently from a query using an appropriate index.

The difference can become substantial as the table grows:

10,000 rows
     |
     v
Small performance difference

10 million rows
     |
     v
Index strategy becomes critical

The benchmark should therefore test realistic data volumes rather than only a development database.

A Typical EF Core Model

A .NET application might represent JSON metadata as a property.

public class Customer
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public CustomerMetadata Metadata { get; set; } = new();
}

The metadata might contain:

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

    public string Segment { get; set; } = string.Empty;

    public bool NotificationsEnabled { get; set; }
}

The database representation can use PostgreSQL jsonb.

The important question is not whether this mapping works.

The important question is:

How does it perform under realistic API workloads?

Define the Benchmark Workload

Before measuring anything, define the operations your application actually performs.

A useful JSONB benchmark should include:

Read complete document
Read one JSON property
Filter by JSON property
Filter by nested property
Search JSON array
Update JSON document
Update one JSON property
Insert JSON document
Bulk insert
Bulk update
Paginated JSON query

These operations have different performance characteristics.

Create a Representative Dataset

Suppose the application has:

5 million customers

Each customer contains:

{
  "region": "north",
  "segment": "enterprise",
  "preferences": {
    "language": "en",
    "notifications": true
  },
  "tags": [
    "premium",
    "priority"
  ]
}

Do not generate identical documents.

Realistic benchmarks should include variation:

Region:
north
south
east
west

Segment:
consumer
business
enterprise

Tags:
1–10 values

Metadata size:
1 KB
5 KB
20 KB
50 KB

Data distribution strongly affects query performance.

Benchmark Different Document Sizes

JSONB performance can change as documents become larger.

Test multiple document sizes:

Document SizeExample Workload
Small1 KB
Medium5 KB
Large20 KB
Very Large50+ KB

A query that performs well with a 1 KB document may become expensive when every row contains a much larger document.

This is particularly relevant when APIs return complete JSON documents.

Measure Insert Performance

A simple EF Core operation might be:

var customer = new Customer
{
    Name = "Customer 1842",
    Metadata = new CustomerMetadata
    {
        Region = "north",
        Segment = "enterprise",
        NotificationsEnabled = true
    }
};

dbContext.Customers.Add(customer);

await dbContext.SaveChangesAsync();

Measure:

Insert latency
Rows per second
Transaction duration
Database CPU
Write amplification

Then repeat with different JSON document sizes.

Benchmark Read Performance

The simplest workload retrieves the complete entity.

var customer = await dbContext.Customers
    .FirstOrDefaultAsync(x => x.Id == customerId);

Measure:

Database execution time
Network transfer
EF Core materialization
Total API latency

It is useful to separate these components.

PostgreSQL
    |
    v
Network
    |
    v
EF Core
    |
    v
ASP.NET API

Otherwise, a slow API response may incorrectly be blamed on PostgreSQL.

Benchmark Property Filtering

A common JSONB workload is filtering by one property.

Conceptually:

WHERE metadata->>'region' = 'north'

The application might expose an API such as:

GET /customers?region=north

The benchmark should measure:

10,000 rows
100,000 rows
1 million rows
10 million rows

and compare execution behavior.

Compare Indexed and Non-Indexed Queries

This is one of the most important benchmark comparisons.

Without an appropriate index, PostgreSQL may scan a large portion of the table.

Query
 |
 v
Sequential Scan
 |
 v
Millions of JSONB values

With a suitable index:

Query
 |
 v
Index
 |
 v
Matching Rows

The benchmark should measure the difference rather than assuming an index is always beneficial.

Test JSONB Indexing Strategies

PostgreSQL supports indexing approaches suited to JSONB workloads.

A common example is a GIN index.

CREATE INDEX idx_customer_metadata
ON customers
USING GIN (metadata);

But index selection should depend on the actual query workload.

For a frequently queried scalar property, a targeted expression index can sometimes be more appropriate.

Conceptually:

CREATE INDEX idx_customer_region
ON customers ((metadata->>'region'));

The benchmark should compare the strategies using real queries.

Do Not Benchmark Only Query Speed

Indexes have costs.

An index can improve:

Read performance

while increasing:

Insert cost
Update cost
Storage usage
Maintenance work

Therefore the benchmark should measure both:

Read Performance
+
Write Performance
+
Storage Cost

A solution that makes reads 2x faster but makes every write significantly more expensive may not be the right design.

Benchmark Nested JSON Properties

Real documents are rarely flat.

For example:

{
  "customer": {
    "region": {
      "country": "India",
      "state": "Bihar"
    }
  }
}

The API may need:

Find customers where country = India

or:

Find customers where state = Bihar

Nested queries should be benchmarked separately because their access patterns can differ from simple scalar properties.

Benchmark JSON Arrays

Arrays introduce another common workload.

Consider:

{
  "tags": [
    "premium",
    "enterprise",
    "priority"
  ]
}

A query may ask:

Find all customers tagged "enterprise".

The benchmark should measure:

No index
Appropriate JSONB index
Alternative normalized representation

This helps determine whether the JSONB model remains appropriate at scale.

Compare JSONB With Relational Columns

Not every frequently queried field should necessarily remain inside JSONB.

Suppose:

{
  "region": "north",
  "segment": "enterprise"
}

is queried by almost every API request.

You could store:

region
segment

as relational columns while keeping less frequently accessed metadata in JSONB.

Customer
|
+---- Region        relational
+---- Segment       relational
|
+---- Metadata      JSONB

This hybrid approach can be useful for high-value query fields.

Benchmark both designs.

A Useful Comparison

DesignReadWriteFlexibilityIndexing
JSONB onlyVariableGoodHighFlexible
Relational onlyPredictablePredictableLowerStrong
HybridOften strongModerateHighTargeted

The correct choice depends on workload rather than ideology.

Benchmark EF Core Projection

Returning the entire JSONB document can be expensive if the API only needs a few fields.

Instead of:

var customers = await dbContext.Customers
    .Where(x => x.Id == customerId)
    .ToListAsync();

consider a projection that retrieves only what the endpoint needs.

Conceptually:

var result = await dbContext.Customers
    .Where(x => x.Id == customerId)
    .Select(x => new
    {
        x.Id,
        x.Name,
        x.Metadata.Region
    })
    .FirstOrDefaultAsync();

The benchmark should compare:

Full document
vs.
Required fields

This can reveal unnecessary data transfer and materialization costs.

Measure API Payload Size

Suppose a JSONB document contains 30 KB of metadata but the API only returns:

{
  "id": 1842,
  "name": "Customer 1842",
  "region": "north"
}

Retrieving the entire document from PostgreSQL and discarding most of it wastes resources.

Measure:

Database bytes
Network bytes
Serialized response size
Client response time

A database benchmark that ignores network transfer can miss a significant part of API performance.

Test Update Workloads

JSONB updates deserve special attention.

For example:

Update customer preferences

may result in a database operation that rewrites more data than expected.

Benchmark:

Update entire document
Update one property
Update multiple properties

Then measure:

Execution time
Write throughput
WAL generation
Storage impact
Lock duration

The difference becomes increasingly important as documents grow.

Compare Whole-Document and Targeted Updates

Conceptually:

Whole Document Update

JSONB
 |
 v
Replace entire document

versus:

Targeted Update

JSONB
 |
 v
Modify selected property

The best strategy depends on the application and update frequency.

A benchmark should use the same workload patterns that occur in production.

Test Concurrent Writes

Single-user tests can hide contention.

For example:

1 request
    |
    v
Update JSONB
    |
    v
Fast

But production may have:

500 concurrent requests
        |
        v
JSONB updates
        |
        v
Database contention

Run concurrency tests such as:

1
10
50
100
500

and measure:

Throughput
P95 latency
P99 latency
Lock waits
Database CPU

Test Concurrent Reads and Writes

A realistic API rarely performs only reads or only writes.

A workload might be:

80% Reads
15% Updates
5% Inserts

Another application might be:

50% Reads
40% Updates
10% Inserts

Run mixed workloads.

Clients
  |
  +---- Read
  +---- Read
  +---- Update
  +---- Read
  +---- Insert
  +---- Update

This provides a much better representation of production behavior.

Benchmark Connection Pooling

A slow JSONB API is not always caused by query execution.

Connection pool exhaustion can create latency even when individual SQL statements are fast.

Measure:

Connection acquisition time
Query execution time
Total database call time

For example:

Connection wait: 80 ms
Query execution: 10 ms
Total:            90 ms

Optimizing the SQL alone will not solve the problem.

Measure Serialization Overhead

The JSONB data has to move through multiple representations:

PostgreSQL JSONB
      |
      v
.NET Object
      |
      v
JSON Response

For large documents, serialization and deserialization can consume meaningful CPU.

Measure:

Database time
EF Core materialization time
Serialization time
Total API time

This helps identify the actual bottleneck.

Benchmark Cold and Warm Cache

Database caching can significantly affect results.

Run separate tests for:

Cold cache
Warm cache

A query might show:

Cold:  300 ms
Warm:   20 ms

Both numbers can be useful.

The important requirement is consistency when comparing PostgreSQL versions or schema designs.

Test Query Plans

For important JSONB queries, capture execution plans.

A conceptual benchmark might record:

Query:
Filter customers by region

Scan:
Index Scan

Estimated Rows:
100,000

Actual Rows:
98,500

Execution:
42 ms

After changing the PostgreSQL version or index:

Scan:
Sequential Scan

Estimated Rows:
100,000

Actual Rows:
98,500

Execution:
740 ms

This is a strong regression signal.

Watch Estimated Versus Actual Rows

JSONB queries can be sensitive to data distribution.

Suppose PostgreSQL estimates:

Estimated: 1,000
Actual:    2,000,000

The optimizer may select an inappropriate execution strategy.

Record:

Estimated rows
Actual rows
Planning time
Execution time

and compare them across benchmark environments.

Benchmark Different Selectivity Levels

A query returning 1% of a table has a different performance profile from one returning 80%.

Test:

Highly selective
Moderately selective
Low selectivity

For example:

Region = rare value
Region = common value

This helps identify when PostgreSQL should use an index and when a sequential scan may be more efficient.

Use a Benchmark Matrix

A serious benchmark can combine several dimensions.

Database Size
     |
     +---- 1M
     +---- 10M
     +---- 100M

Document Size
     |
     +---- 1 KB
     +---- 10 KB
     +---- 50 KB

Concurrency
     |
     +---- 1
     +---- 50
     +---- 500

Query Selectivity
     |
     +---- High
     +---- Medium
     +---- Low

This creates a much more realistic performance profile.

Example Benchmark Results

A benchmark report might look like:

WorkloadBaselineNew VersionChange
JSONB point lookup8 ms9 ms+12.5%
Region filter42 ms45 ms+7.1%
Nested property search85 ms92 ms+8.2%
Array search110 ms118 ms+7.3%
Large document read140 ms170 ms+21.4%
Bulk JSONB update620 ms690 ms+11.3%

These numbers are illustrative. Your benchmark should use your own workload and environment.

Define Regression Thresholds

A practical policy might classify results as:

Green:
< 10% degradation

Yellow:
10–25% degradation

Red:
> 25% degradation

But thresholds should also consider absolute latency.

For example:

5 ms -> 8 ms

may be acceptable.

While:

800 ms -> 1,100 ms

may require immediate investigation even if the workload is infrequent.

Test JSONB Schema Evolution

Flexible JSON does not eliminate schema changes.

Suppose version one contains:

{
  "region": "north"
}

and version two introduces:

{
  "region": "north",
  "segment": "enterprise"
}

Your application may need to support both.

Benchmark:

Old document
New document
Mixed document population

Mixed schemas are particularly important during gradual deployments.

Test Missing Properties

Some documents may not contain a field.

For example:

{
  "region": "north"
}

while another contains:

{
  "region": "north",
  "preferences": {
    "language": "en"
  }
}

The API should behave predictably when nested fields are absent.

Performance tests should include these cases because null or missing-value handling can affect query behavior.

Test Large JSONB Documents

Large JSON documents can create unexpected application costs.

Benchmark documents containing:

1 KB
10 KB
50 KB
100 KB
500 KB

and measure:

Insert
Read
Update
Network transfer
Serialization
Memory

At some point, the JSONB document may be carrying information that should be modeled separately.

Benchmarking helps identify that boundary.

Monitor Memory Usage

Large JSONB documents can increase memory usage at several levels:

PostgreSQL
    |
    v
Network Buffer
    |
    v
.NET
    |
    v
EF Core
    |
    v
JSON Serializer

Measure application memory during large-document workloads.

A query can be fast while still causing unacceptable memory pressure.

Test Pagination With JSONB Filters

An API might expose:

GET /customers?region=north&page=100

Benchmark different pages.

Page 1
Page 10
Page 100
Page 1,000

A JSONB filter combined with deep pagination can expose inefficient plans that are not visible in simple first-page tests.

Compare Offset and Keyset Pagination

For large datasets, compare:

OFFSET + LIMIT

against keyset-style pagination.

Conceptually:

Offset pagination
    |
    v
Skip many rows
    |
    v
Return page

versus:

Keyset pagination
    |
    v
Start after known key
    |
    v
Return next page

The benchmark should use production-like page depths.

Create a .NET Benchmark Harness

A simple harness can execute the same EF Core query repeatedly.

public async Task<BenchmarkResult> RunAsync(
    int customerId)
{
    var stopwatch = Stopwatch.StartNew();

    var result = await dbContext.Customers
        .Where(x => x.Id == customerId)
        .Select(x => new
        {
            x.Id,
            x.Name,
            x.Metadata.Region
        })
        .FirstOrDefaultAsync();

    stopwatch.Stop();

    return new BenchmarkResult
    {
        Duration = stopwatch.Elapsed,
        Found = result != null
    };
}

Run multiple iterations and discard obvious warm-up effects.

Then calculate:

P50
P95
P99
Minimum
Maximum
Throughput

Avoid Benchmarking From a Developer Laptop

Application-level measurements from a local machine can contain unrelated noise.

For database upgrade testing, use controlled environments.

Benchmark Client
      |
      v
Controlled Network
      |
      v
PostgreSQL Instance

Keep CPU, memory, network, connection settings, and dataset consistent where practical.

Build a Regression Pipeline

The benchmark can become part of database upgrade testing.

Schema
  |
  v
Load Dataset
  |
  v
Prepare Statistics
  |
  v
Run EF Core Workload
  |
  v
Capture Plans
  |
  v
Compare Results
  |
  +---- Pass
  |
  +---- Investigate

This prevents performance validation from becoming a manual activity performed only before major releases.

Common Mistakes

Storing Everything in JSONB

JSONB is flexible, but frequently queried fields may be better represented as relational columns.

Benchmarking Only Small Documents

Large documents can change the performance characteristics significantly.

Measuring Only SQL Execution Time

API latency also includes connection acquisition, network transfer, materialization, and serialization.

Ignoring Index Write Costs

Indexes improve some reads while adding storage and write overhead.

Testing Only One Parameter

Different values can produce different plans and execution times.

Using Unrealistic Data

Uniform synthetic data can hide real production selectivity problems.

Ignoring Concurrent Workloads

A query that performs well alone may behave differently under contention.

Treating JSONB as a Free Schema

Flexible structure still requires versioning, validation, and migration discipline.

Best Practices

Benchmark Real Query Patterns

Start with the operations your APIs actually perform.

Use Production-Like Data

Data size and distribution matter.

Measure Both Database and API Performance

Find out whether the bottleneck is PostgreSQL, EF Core, serialization, or the network.

Test Index Strategies

Compare indexing approaches with real workloads.

Measure Read and Write Costs

Do not optimize reads in isolation.

Test Multiple Document Sizes

JSONB performance changes as documents become larger.

Include Concurrency

Measure behavior under realistic traffic.

Capture Execution Plans

Latency tells you that something changed; the plan often helps explain why.

Track P95 and P99

Tail latency matters for APIs.

Automate Regression Detection

Make JSONB performance testing part of database upgrade and schema-change workflows.

A Practical JSONB Benchmark Checklist

Before approving a PostgreSQL upgrade or major JSONB schema change:

[ ] Identify critical JSONB queries
[ ] Capture EF Core generated SQL
[ ] Prepare production-like data
[ ] Test multiple document sizes
[ ] Test scalar property filters
[ ] Test nested properties
[ ] Test arrays
[ ] Compare index strategies
[ ] Test inserts
[ ] Test updates
[ ] Test reads
[ ] Test mixed workloads
[ ] Test concurrency
[ ] Test pagination
[ ] Capture execution plans
[ ] Compare estimated and actual rows
[ ] Measure P50/P95/P99
[ ] Measure API payload size
[ ] Measure serialization overhead
[ ] Test schema evolution
[ ] Define regression thresholds
[ ] Automate benchmark reporting

Conclusion

PostgreSQL JSONB is a powerful option for .NET applications that need flexible data structures, but its performance depends heavily on workload design. The same JSONB model can perform well for one application and become expensive for another depending on document size, query selectivity, indexing, update frequency, and concurrency.

For EF Core applications, the most useful benchmark does not stop at measuring SQL execution time. It follows the complete request path from the .NET API through EF Core and PostgreSQL and measures database execution, network transfer, materialization, serialization, and end-to-end latency.

When evaluating a PostgreSQL upgrade, capture a baseline first, use the same production-like dataset, compare execution plans, test multiple document sizes and parameter distributions, and measure both reads and writes. Most importantly, benchmark the queries your APIs actually execute rather than relying on isolated database tests.

The goal is not to prove that JSONB is faster than relational modeling. The goal is to understand where JSONB works well, where it needs careful indexing, and where frequently accessed data should move into a more predictable relational structure. That evidence makes PostgreSQL and EF Core architecture decisions much easier to defend before a workload reaches production.