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:
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 Size | Example Workload |
|---|
| Small | 1 KB |
| Medium | 5 KB |
| Large | 20 KB |
| Very Large | 50+ 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
| Design | Read | Write | Flexibility | Indexing |
|---|
| JSONB only | Variable | Good | High | Flexible |
| Relational only | Predictable | Predictable | Lower | Strong |
| Hybrid | Often strong | Moderate | High | Targeted |
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:
| Workload | Baseline | New Version | Change |
|---|
| JSONB point lookup | 8 ms | 9 ms | +12.5% |
| Region filter | 42 ms | 45 ms | +7.1% |
| Nested property search | 85 ms | 92 ms | +8.2% |
| Array search | 110 ms | 118 ms | +7.3% |
| Large document read | 140 ms | 170 ms | +21.4% |
| Bulk JSONB update | 620 ms | 690 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.