Database performance is often discussed in terms of queries, indexes, and CPU usage.
But for many real applications, storage I/O is still an important part of the execution path.
A query may need to:
Read table pages
|
v
Read index pages
|
v
Fetch heap pages
|
v
Return rows
When those reads cannot be served from memory, storage latency becomes part of query latency.
PostgreSQL 18 introduces a new asynchronous I/O subsystem designed to allow multiple I/O requests to be processed concurrently. The initial AIO support covers workloads including sequential scans, bitmap heap scans, and vacuum operations. PostgreSQL also provides configuration options for selecting the I/O method and controlling concurrency.
For .NET developers, the interesting question is not simply:
"Is PostgreSQL 18 AIO faster?"
A more useful question is:
"What difference does PostgreSQL 18 AIO make when a real ASP.NET Core application accesses PostgreSQL through EF Core?"
That requires measuring the complete path:
ASP.NET Core
|
v
EF Core
|
v
Database Driver
|
v
PostgreSQL 18
|
v
Storage
This article presents a practical benchmark methodology for that scenario.
What PostgreSQL 18 AIO Changes
Before PostgreSQL 18, database I/O generally depended more heavily on synchronous operations and operating-system readahead.
PostgreSQL 18 introduces an AIO subsystem that allows the database to queue multiple I/O requests rather than waiting for individual requests sequentially. The release documentation specifically identifies sequential scans, bitmap heap scans, and vacuum among the workloads that can benefit.
The key idea is:
Traditional approach
Read page 1
|
Wait
|
Read page 2
|
Wait
|
Read page 3
versus:
AIO
Submit page 1
Submit page 2
Submit page 3
Submit page 4
|
v
Process completions
This does not mean every PostgreSQL query automatically becomes faster.
The benefit depends heavily on whether the workload is actually I/O-bound.
PostgreSQL 18 AIO Methods
PostgreSQL 18 provides the io_method setting.
The documented options include:
worker
io_uring
sync
The worker method uses PostgreSQL I/O worker processes.
The io_uring method uses the Linux io_uring interface when PostgreSQL has been built with the required support.
The sync method preserves synchronous execution for AIO-eligible operations.
A simplified configuration looks like:
io_method = worker
or, on an appropriate Linux deployment:
io_method = io_uring
For comparison:
io_method = sync
can be used as a baseline.
Do not assume that io_uring is automatically the fastest option.
Benchmark the actual environment.
Why EF Core Changes the Benchmark
A database-only benchmark answers:
How fast can PostgreSQL execute this workload?
An EF Core benchmark answers a broader question:
How does the application behave when
EF Core generates and executes the workload?
The complete path includes:
LINQ
|
v
EF Core Query Translation
|
v
SQL
|
v
Database Driver
|
v
PostgreSQL
|
v
Storage
|
v
Result Materialization
If you measure only SQL execution time, you are not measuring the application experience.
If you measure only HTTP response time, you may not know where the time was spent.
A good benchmark measures both.
Define the Benchmark Goal
Start with a specific hypothesis.
For example:
Hypothesis:
PostgreSQL 18 AIO reduces execution time for
I/O-bound EF Core queries over large datasets.
Then define what you will measure:
This prevents the benchmark from becoming a collection of unrelated numbers.
Build a Realistic .NET Model
Consider an order-management application.
public sealed class Order
{
public long Id { get; set; }
public long CustomerId { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public string Region { get; set; } = null!;
}
Configure it with EF Core:
public sealed class AppDbContext
: DbContext
{
public DbSet<Order> Orders => Set<Order>();
public AppDbContext(
DbContextOptions<AppDbContext> options)
: base(options)
{
}
}
The model should resemble the workload you actually care about.
Avoid creating a benchmark table with one integer column and calling it a production benchmark.
Generate a Large Dataset
A small dataset may fit entirely in memory.
That can hide storage behavior.
For example:
INSERT INTO orders
(
customer_id,
total_amount,
status,
created_at,
region
)
SELECT
(random() * 1000000)::bigint,
round((random() * 10000)::numeric, 2),
CASE
WHEN random() < 0.7 THEN 'Completed'
WHEN random() < 0.9 THEN 'Pending'
ELSE 'Cancelled'
END,
now() - (random() * interval '365 days'),
CASE
WHEN random() < 0.25 THEN 'North'
WHEN random() < 0.50 THEN 'South'
WHEN random() < 0.75 THEN 'East'
ELSE 'West'
END
FROM generate_series(1, 10000000);
The exact row count should depend on your hardware.
The objective is to create a dataset large enough to expose the behavior you are trying to measure.
Do Not Benchmark Only Warm Cache
A warm-cache query may behave very differently from an I/O-bound query.
Consider:
Cold / pressured cache
|
v
Storage reads
|
v
AIO can matter
Warm cache
|
v
Shared memory
|
v
Less storage I/O
If every benchmark query is served from memory, changing the I/O method may have little visible effect.
That does not mean AIO is ineffective.
It means the workload is not exercising the relevant part of the system.
Design Three Test Scenarios
A useful EF Core benchmark can include three categories.
Scenario 1: Point Lookup
var order = await db.Orders
.SingleOrDefaultAsync(
x => x.Id == orderId,
cancellationToken);
This primarily exercises an indexed lookup.
Scenario 2: Filtered Query
var orders = await db.Orders
.Where(x =>
x.Region == region &&
x.Status == "Completed")
.OrderByDescending(x => x.CreatedAt)
.Take(100)
.ToListAsync(cancellationToken);
This can involve index access, filtering, sorting, and row retrieval.
Scenario 3: Large Scan
var count = await db.Orders
.Where(x =>
x.CreatedAt >= startDate &&
x.CreatedAt < endDate)
.CountAsync(cancellationToken);
Depending on indexes, data distribution, and planner decisions, PostgreSQL may choose different access strategies.
This is where I/O behavior becomes especially interesting.
Sequential Scans Matter
A sequential scan reads a substantial portion of a table.
For example:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE total_amount > 5000;
If the predicate matches a large percentage of the table, PostgreSQL may choose a sequential scan.
This is one of the workloads explicitly identified as benefiting from PostgreSQL 18 AIO.
Bitmap Heap Scans Matter Too
A different query might produce a bitmap-based plan.
For example:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE region = 'North'
AND status = 'Completed';
Depending on the available indexes and data distribution, PostgreSQL may use a bitmap index scan followed by a bitmap heap scan.
Bitmap heap scans are also among the AIO-supported workloads identified in PostgreSQL 18.
Create Appropriate Indexes
A benchmark needs realistic indexes.
For example:
CREATE INDEX ix_orders_region_status
ON orders(region, status);
And:
CREATE INDEX ix_orders_created_at
ON orders(created_at);
But do not add every possible index.
Excessive indexing changes:
Storage requirements
Write cost
Query plans
Cache behavior
Maintenance workload
The benchmark should represent a plausible application schema.
Compare the Query Plan
Before measuring application performance, inspect the SQL plan.
Use:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE region = 'North'
AND status = 'Completed';
Record:
Execution Time
Planning Time
Shared Hit Blocks
Shared Read Blocks
Shared Dirtied Blocks
Shared Written Blocks
This helps determine whether the query is actually performing physical reads.
BUFFERS Is Essential
Consider two results:
Query A
Shared Hit Blocks: 500000
Shared Read Blocks: 10
and:
Query B
Shared Hit Blocks: 100000
Shared Read Blocks: 450000
The second query is much more dependent on reading data from storage.
That distinction matters when evaluating AIO.
If your benchmark contains almost no physical reads, it may not meaningfully exercise the feature.
Use pg_stat_io
PostgreSQL 18 adds additional I/O statistics.
The pg_stat_io view includes byte-oriented measurements such as:
read_bytes
write_bytes
extend_bytes
The release notes describe these additions as part of PostgreSQL 18's I/O observability improvements.
A useful query is:
SELECT
backend_type,
object,
context,
reads,
read_bytes,
writes,
write_bytes
FROM pg_stat_io;
The exact columns and categories available should be checked against the PostgreSQL version being benchmarked.
Inspect Active AIO Operations
PostgreSQL 18 also provides the pg_aios view.
It exposes currently active asynchronous I/O handles and their states. The documentation notes that it is primarily useful for developers but can also assist with tuning.
For example:
SELECT
pid,
io_id,
state
FROM pg_aios;
This is useful when investigating whether AIO is actually active during a workload.
Do not interpret an empty snapshot as proof that AIO is not working.
A query may finish before you inspect the view.
Measure From EF Core
The database plan is only one layer.
Measure the application query:
var stopwatch = Stopwatch.StartNew();
var orders = await db.Orders
.Where(x =>
x.Region == region &&
x.Status == "Completed")
.OrderByDescending(x => x.CreatedAt)
.Take(100)
.ToListAsync(cancellationToken);
stopwatch.Stop();
Console.WriteLine(
$"Elapsed: {stopwatch.ElapsedMilliseconds} ms");
This measures:
EF Core
+
Driver
+
Database
+
Network
+
Materialization
It does not isolate database execution time.
That is why both application-level and database-level measurements are necessary.
Avoid Measuring Only First-Request Latency
ASP.NET Core applications have startup costs.
The first request may include:
Dependency injection
DbContext creation
Connection establishment
Query compilation
JIT
Application initialization
If you compare only the first request, you may measure startup behavior rather than database I/O.
Run a warm-up phase:
Start application
|
v
Warm-up requests
|
v
Benchmark
|
v
Collect measurements
Disable Unrelated Application Work
A database benchmark should not accidentally become a benchmark of:
Logging
Telemetry
Authentication
External APIs
Background jobs
Caching
Keep the benchmark path controlled.
For example:
var orders = await db.Orders
.AsNoTracking()
.Where(x => x.Region == region)
.ToListAsync(cancellationToken);
AsNoTracking() can be appropriate for read-only benchmarks because change tracking itself adds application-side work.
But use it only when it represents the intended production query.
Tracking vs No-Tracking
Compare deliberately.
Tracking
var orders = await db.Orders
.Where(x => x.Status == "Pending")
.ToListAsync(cancellationToken);
No Tracking
var orders = await db.Orders
.AsNoTracking()
.Where(x => x.Status == "Pending")
.ToListAsync(cancellationToken);
If the production application uses tracking, benchmark tracking.
If it uses no-tracking queries for read-heavy operations, benchmark that configuration.
Do not change EF Core behavior just to make the database look faster.
Measure SQL Translation Separately
EF Core query execution has multiple components.
LINQ
|
v
Expression Processing
|
v
SQL Generation
|
v
Database Execution
|
v
Materialization
A database AIO feature primarily affects database-side I/O.
If the total application latency changes by only a small amount, it may be because database I/O is only one part of the request.
This is why end-to-end benchmarks need layer-specific measurements.
Use ToQueryString() During Benchmark Development
You can inspect generated SQL with:
var query = db.Orders
.Where(x =>
x.Region == region &&
x.Status == "Completed");
Console.WriteLine(
query.ToQueryString());
This is useful for confirming that the benchmark is executing the SQL you intended.
Do not use this output as the production query-execution mechanism.
Build a Benchmark Service
A simple application-level benchmark service can look like:
public sealed class OrderBenchmark
{
private readonly IDbContextFactory<AppDbContext>
_factory;
public OrderBenchmark(
IDbContextFactory<AppDbContext> factory)
{
_factory = factory;
}
public async Task<long> RunAsync(
CancellationToken cancellationToken)
{
await using var db =
await _factory.CreateDbContextAsync(
cancellationToken);
var stopwatch = Stopwatch.StartNew();
await db.Orders
.AsNoTracking()
.Where(x =>
x.Region == "North" &&
x.Status == "Completed")
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.ToListAsync(cancellationToken);
stopwatch.Stop();
return stopwatch.ElapsedMilliseconds;
}
}
Using a context factory makes repeated benchmark iterations easier to control.
Benchmark worker, io_uring, and sync
A useful PostgreSQL comparison is:
Configuration A
io_method = sync
Configuration B
io_method = worker
Configuration C
io_method = io_uring
Run the same workload against each.
The test environment must remain consistent:
Same database
Same dataset
Same indexes
Same PostgreSQL configuration
Same storage
Same EF Core application
Same query
Same concurrency
Change only the variable under investigation.
Be Careful With io_uring
The io_uring method requires appropriate build and operating-system support. PostgreSQL documents that it requires a build with the necessary liburing support.
Therefore, document:
Operating system
Kernel version
PostgreSQL build
io_uring availability
Storage device
Filesystem
Otherwise, another developer may not be able to reproduce the results.
worker Is a Useful Baseline
The worker-based implementation is particularly useful because it does not depend on io_uring.
The PostgreSQL configuration documentation describes worker as asynchronous I/O implemented using worker processes.
That makes it useful for environments where:
io_uring unavailable
or where a portable configuration is preferred.
Tune I/O Concurrency Carefully
PostgreSQL exposes:
effective_io_concurrency
maintenance_io_concurrency
io_max_concurrency
The current documentation describes effective_io_concurrency as controlling the number of concurrent storage I/O operations PostgreSQL expects can be executed simultaneously. Higher values can have more impact on higher-latency storage, while unnecessarily high values can increase latency.
Do not simply increase every I/O setting.
Benchmark each change.
Example Configuration Matrix
A test matrix could look like:
| Test | I/O Method | Concurrency |
|---|
| Baseline | sync | Default |
| AIO | worker | Default |
| AIO | io_uring | Default |
| AIO | worker | Higher |
| AIO | io_uring | Higher |
Record the results independently.
This helps distinguish:
AIO benefit
from:
I/O concurrency tuning benefit
Test With Different Dataset Sizes
AIO behavior may vary depending on whether the working set fits in memory.
Test at least:
Small
Medium
Large
For example:
| Dataset | Purpose |
|---|
| 1 GB | Mostly cache-friendly |
| 10 GB | Mixed behavior |
| 100 GB | Stronger storage pressure |
The actual sizes should match the hardware.
The important concept is to test workloads that have different cache characteristics.
Test Different Storage
If practical, compare:
Local NVMe
Network-attached storage
Virtualized storage
Storage latency and parallel I/O capabilities can influence the benefit of asynchronous I/O.
Do not transfer a result from one storage system directly to another.
Test Sequential Workloads
A sequential scan is a natural AIO workload.
For example:
SELECT COUNT(*)
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '180 days';
Depending on indexes and data distribution, PostgreSQL may choose a sequential scan.
Inspect:
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '180 days';
Record the actual plan.
Test Bitmap Workloads
Create a selective index:
CREATE INDEX ix_orders_status
ON orders(status);
Then:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE status = 'Pending';
Whether PostgreSQL chooses a bitmap plan depends on the data distribution and planner cost model.
That is precisely why inspecting the plan is important.
Test Vacuum Separately
AIO also applies to vacuum-related I/O.
Do not combine vacuum measurements with application query measurements.
Instead:
Benchmark A
-----------
EF Core queries
Benchmark B
-----------
VACUUM
Benchmark C
-----------
EF Core queries + maintenance activity
This can show whether AIO affects application workloads, maintenance workloads, or both.
Benchmark Under Concurrency
A single EF Core request is not enough for an enterprise workload.
Test:
1 client
4 clients
16 clients
32 clients
64 clients
where the hardware can support it.
The benchmark should record:
Throughput
p50 latency
p95 latency
p99 latency
CPU
I/O
Error rate
Do not assume higher concurrency means better throughput.
At some point, the database or storage system may saturate.
Use Parallel Application Workers
A simple benchmark harness can use:
await Parallel.ForEachAsync(
Enumerable.Range(0, workerCount),
cancellationToken,
async (_, token) =>
{
await benchmark.RunAsync(token);
});
For serious benchmark work, use a dedicated load-generation approach rather than relying solely on Parallel.ForEachAsync.
The important point is that concurrency should be controlled and repeatable.
Measure p95 and p99
Average latency can hide important tail behavior.
Consider:
Average: 100 ms
p95: 140 ms
p99: 900 ms
The average looks reasonable.
But one percent of requests take nearly a second.
For interactive APIs, that matters.
A useful benchmark therefore reports:
p50
p95
p99
alongside throughput.
Avoid Benchmarking the Wrong Query
Suppose you want to test I/O but your query uses:
WHERE id = ?
with a highly selective primary key.
That may be a poor workload for evaluating broad storage-read behavior.
A better benchmark may include:
Large filtered query
Large range query
Sequential scan
Bitmap heap scan
These can exercise more substantial data access.
Measure Database CPU
AIO can change how efficiently I/O waits are handled.
But if the workload becomes CPU-bound, more efficient I/O may not improve end-to-end latency.
Your benchmark should therefore track:
CPU utilization
Storage utilization
Memory
Database latency
Application latency
A useful interpretation looks like:
Storage saturated
|
v
AIO may help
CPU saturated
|
v
AIO may have limited effect
Understand the EF Core Materialization Cost
Suppose PostgreSQL returns 500,000 rows.
Even if PostgreSQL becomes faster at reading them, EF Core still needs to:
Receive rows
|
v
Create objects
|
v
Populate properties
|
v
Track entities if enabled
The application may therefore remain slow.
Avoid using a huge result set unless that behavior represents a real application workload.
Project Only Required Columns
Instead of:
var orders = await db.Orders
.AsNoTracking()
.ToListAsync(cancellationToken);
consider:
var orders = await db.Orders
.AsNoTracking()
.Select(x => new OrderSummary
{
Id = x.Id,
TotalAmount = x.TotalAmount,
Status = x.Status,
CreatedAt = x.CreatedAt
})
.ToListAsync(cancellationToken);
This reduces data transfer and materialization work.
However, when benchmarking AIO specifically, make sure the query still exercises the database I/O characteristics you intend to measure.
Compare EF Core With Raw SQL
A useful diagnostic is to compare:
EF Core
against:
Equivalent SQL
If both improve similarly when switching AIO modes:
Likely database-side effect
If raw SQL changes substantially but EF Core changes very little:
Application-side overhead may dominate
This distinction is valuable when explaining benchmark results.
Keep Connection Pooling Consistent
Connection pooling can affect application latency.
Use the same:
Pool size
Connection string
Context lifetime
Concurrency
across all benchmark runs.
Do not change pooling configuration between AIO tests.
Otherwise, you are measuring multiple variables simultaneously.
Control Query Compilation
EF Core can incur query compilation overhead.
If the benchmark repeatedly constructs different query shapes, you may accidentally measure:
Query compilation
+
Database execution
rather than only database behavior.
Use stable query shapes.
For hot paths, compiled queries may also be evaluated separately.
Example Compiled Query
A compiled query can be defined as:
private static readonly Func<
AppDbContext,
string,
IAsyncEnumerable<Order>>
OrdersByRegion =
EF.CompileAsyncQuery(
(AppDbContext db, string region) =>
db.Orders
.AsNoTracking()
.Where(x =>
x.Region == region));
Then:
await foreach (
var order in OrdersByRegion(
db,
"North"))
{
// Process order
}
Do not introduce compiled queries merely to improve an AIO benchmark.
Use them when they represent the application's real access pattern.
Benchmark With and Without Application Caching
If your production application uses caching:
Request
|
v
Cache
|
+--> Hit
|
+--> Miss --> PostgreSQL
a cache-heavy benchmark may barely exercise PostgreSQL.
Therefore, report cache state explicitly:
Database-only
Application cache disabled
Application cache enabled
This provides a clearer picture.
A Recommended Benchmark Workflow
Use the following process.
Step 1: Establish a Baseline
Run PostgreSQL 18 with:
io_method = sync
Measure the application.
Step 2: Enable Worker-Based AIO
Change:
io_method = worker
Repeat the exact workload.
Step 3: Test io_uring
Where supported:
io_method = io_uring
Repeat the workload.
Step 4: Inspect Database Metrics
Collect:
EXPLAIN ANALYZE
pg_stat_io
pg_aios
CPU
Storage
Step 5: Compare Application Metrics
Collect:
p50
p95
p99
Throughput
Errors
Step 6: Repeat
Run multiple iterations.
Do not publish conclusions from one execution.
Example Benchmark Result Format
Use a table like:
| Configuration | p50 | p95 | p99 | Throughput |
|---|
| sync | Measured | Measured | Measured | Measured |
| worker | Measured | Measured | Measured | Measured |
| io_uring | Measured | Measured | Measured | Measured |
Do not fill these values with assumed numbers.
A benchmark article is stronger when the numbers are actually measured on the documented environment.
Benchmark Environment Must Be Documented
At minimum, record:
PostgreSQL version
Operating system
Kernel version
CPU
RAM
Storage
Filesystem
Dataset size
Indexes
shared_buffers
effective_io_concurrency
io_method
Application runtime
EF Core version
Database driver version
Connection pool settings
Concurrency
Without this information, reproducing the benchmark becomes difficult.
Do Not Claim AIO Makes Every Query Faster
PostgreSQL's AIO implementation targets specific I/O patterns.
The release documentation identifies sequential scans, bitmap heap scans, vacuum, and related operations as areas where asynchronous I/O can improve behavior.
A highly selective cached lookup may show little difference.
For example:
Primary-key lookup
|
v
Page already in memory
|
v
Very little physical I/O
There is little reason to expect a major AIO improvement in such a case.
What a Good Benchmark Should Prove
A useful article should answer:
1. Does the workload perform physical I/O?
2. Which query plans are I/O-heavy?
3. Does AIO change database execution time?
4. Does that improvement reach EF Core?
5. Does it improve API latency?
6. Does concurrency change the result?
7. Does storage type change the result?
8. Which AIO method performs best in this environment?
9. What is the operational cost?
10. Should this workload actually enable or tune AIO?
This is much more valuable than a single "before vs after" number.
Common Mistakes
Benchmarking Only a Small Dataset
Everything may fit into memory.
Measuring Only Average Latency
Tail latency can hide important behavior.
Changing Multiple Settings at Once
You will not know which change produced the result.
Benchmarking Only SQL
That does not measure the complete EF Core application path.
Benchmarking Only HTTP
That hides database-level behavior.
Ignoring Query Plans
You may think you are measuring AIO while actually measuring an index lookup.
Using Unrealistic Result Sizes
Large materialization costs can dominate database performance.
Ignoring Cache State
Cold and warm workloads can behave very differently.
Treating io_uring as Automatically Better
The best configuration depends on the operating system, storage, workload, and PostgreSQL build.
Publishing Unsupported Percentages
A benchmark result belongs to its test environment.
Troubleshooting Unexpected Results
AIO Shows No Improvement
Check:
Physical reads
Query plan
Dataset size
Cache state
Storage latency
CPU utilization
If the workload is mostly served from memory, there may be little AIO work to optimize.
io_uring Performs Worse Than worker
That does not necessarily indicate a configuration problem.
Check:
Kernel
PostgreSQL build
liburing support
Storage
Workload
Cache state
Concurrency
Measure rather than assuming.
EF Core Improves Less Than Raw SQL
Inspect:
Materialization
Network transfer
Tracking
Query compilation
Application CPU
Connection pooling
The database may be faster while application-side work remains the bottleneck.
Query Plans Change Between Runs
Check:
Statistics
Data distribution
Cache state
Indexes
Planner settings
Dataset state
Run ANALYZE where appropriate before benchmarking.
High Concurrency Makes Everything Slower
Check:
CPU saturation
Storage saturation
Connection pool limits
Database connections
I/O concurrency
Memory pressure
AIO does not remove hardware limits.
Best Practices
Benchmark PostgreSQL AIO with realistic EF Core workloads.
Establish a synchronous baseline before enabling AIO.
Test worker and io_uring independently where supported.
Keep the dataset large enough to exercise the storage path.
Inspect query plans with EXPLAIN (ANALYZE, BUFFERS).
Use PostgreSQL I/O statistics to validate physical I/O behavior.
Measure p50, p95, and p99 latency.
Measure database execution and application execution separately.
Keep EF Core configuration consistent across benchmark runs.
Control connection pooling and concurrency.
Test both cold-pressure and warm-cache scenarios where relevant.
Benchmark sequential and bitmap-heavy workloads.
Do not assume every query benefits from AIO.
Tune I/O concurrency only after establishing a baseline.
Document hardware and software versions.
Repeat benchmark runs and report variability.
Do not publish generalized performance claims from a single environment.
Choose the configuration based on measured workload behavior rather than theoretical expectations.
A Production-Oriented .NET Architecture
A realistic application path might look like:
ASP.NET Core
|
v
Application Service
|
v
EF Core
|
v
Npgsql
|
v
PostgreSQL 18
|
+---------+---------+
| |
v v
AIO Layer Shared Buffers
|
v
Storage
This architecture highlights an important point.
AIO is not an EF Core feature.
EF Core does not need to know that PostgreSQL is using asynchronous storage I/O.
The application continues to execute queries normally.
The database handles the I/O scheduling internally.
That makes AIO interesting from an operational perspective: application code can remain unchanged while the database execution environment changes.
Should You Change Application Code?
Usually, the first AIO experiment should require no application-code changes.
Keep:
await db.Orders
.AsNoTracking()
.Where(...)
.ToListAsync(cancellationToken);
exactly the same.
Then compare:
PostgreSQL configuration A
vs
PostgreSQL configuration B
This isolates the database change.
Only after identifying a database-level improvement should you investigate application-level optimizations.
AIO and Application Scaling
Suppose the application currently scales horizontally:
API 1 ----\
API 2 -----\
API 3 ------> PostgreSQL
API 4 -----/
Adding more application instances can increase database concurrency.
If storage becomes the bottleneck:
More API instances
|
v
More DB requests
|
v
Storage pressure
AIO may help certain workloads use available I/O parallelism more effectively.
But it does not create unlimited storage capacity.
If the underlying storage is saturated, additional application concurrency may still reduce performance.
AIO and Maintenance Workloads
Application traffic is not the only workload accessing storage.
PostgreSQL also performs:
VACUUM
ANALYZE
Index maintenance
WAL activity
Table growth
Background operations
PostgreSQL 18's AIO improvements extend to maintenance-related operations such as vacuum.
Therefore, a production benchmark should consider whether maintenance activity runs simultaneously with application traffic.
A realistic test may look like:
Application Queries
+
Concurrent Maintenance
|
v
PostgreSQL 18
|
v
Storage
AIO Should Be Evaluated as a System Feature
The biggest mistake is treating AIO as a single configuration switch.
A more useful model is:
AIO
|
+--> I/O method
|
+--> I/O concurrency
|
+--> Storage
|
+--> Cache
|
+--> Query plan
|
+--> Dataset size
|
+--> Workload concurrency
|
+--> Maintenance activity
The result emerges from the complete system.
Conclusion
PostgreSQL 18's asynchronous I/O subsystem is an important database-engineering change because it allows PostgreSQL to issue and process multiple I/O operations concurrently instead of relying entirely on sequential waiting. PostgreSQL identifies sequential scans, bitmap heap scans, vacuum, and related workloads as areas where AIO can provide benefits.
For .NET developers, however, the interesting question is not whether a database benchmark improves in isolation.
The real question is whether that improvement survives the complete application path:
ASP.NET Core
|
v
EF Core
|
v
Database Driver
|
v
PostgreSQL AIO
|
v
Storage
A good benchmark should therefore measure:
Query Plan
+
Physical I/O
+
Database Execution
+
EF Core Execution
+
Application Latency
+
Concurrency
+
Storage Behavior
The strongest methodology is to establish a sync baseline, compare it with worker and, where supported, io_uring, and keep the application workload identical.
Most importantly, do not assume that AIO will improve every query.
A cached primary-key lookup, an I/O-heavy sequential scan, and a large bitmap heap scan are very different workloads.
The engineering decision should come from measurement.
PostgreSQL 18 AIO is not a reason to rewrite EF Core queries. It is a reason to benchmark the database layer under the workloads your application actually runs.
Frequently Asked Questions
What is AIO in PostgreSQL 18?
AIO, or asynchronous I/O, allows PostgreSQL to manage multiple I/O requests concurrently rather than waiting for each request individually. PostgreSQL 18 introduced the subsystem for workloads including sequential scans, bitmap heap scans, and vacuum.
Does EF Core need special code to use PostgreSQL AIO?
No. AIO is implemented inside PostgreSQL. An EF Core application can continue issuing normal queries while PostgreSQL manages the underlying I/O according to its configuration.
Does AIO make every EF Core query faster?
No. AIO is most relevant to workloads that perform meaningful storage I/O. Highly selective queries whose required pages are already cached may show little difference.
What is the difference between worker, io_uring, and sync?
sync performs AIO-eligible operations synchronously. worker uses PostgreSQL worker processes, while io_uring uses the Linux io_uring mechanism when PostgreSQL has been built with the required support.
Should io_uring always be preferred?
No. Its performance depends on the operating system, PostgreSQL build, storage system, cache behavior, workload, and concurrency. Benchmark it against the worker implementation in the actual environment.
How do I know whether my query is I/O-bound?
Start with:
EXPLAIN (ANALYZE, BUFFERS)
and examine shared hits and reads. PostgreSQL's I/O statistics can provide additional information about physical I/O activity.
Why is my EF Core benchmark not improving even though PostgreSQL is faster?
EF Core may spend significant time on materialization, tracking, query processing, network transfer, or other application work. Measure database execution and application execution separately.
What dataset size should I use?
There is no universal size. Use a dataset large enough to represent the application's production characteristics and, when testing storage I/O, large enough to create meaningful cache pressure.
Should I change effective_io_concurrency when enabling AIO?
Not automatically. Establish a baseline first, then change one configuration variable at a time and measure the result. Excessively high concurrency can increase I/O latency.
What should I report in an AIO benchmark?
At minimum, report PostgreSQL version, operating system, hardware, storage, dataset size, indexes, I/O configuration, EF Core version, driver version, concurrency, query plans, p50/p95/p99 latency, throughput, and relevant PostgreSQL I/O statistics.