In this post, we show how Dynata simplified database cost optimization and accelerated modernization to AWS Graviton processors by adopting Database Savings Plans. Rather than managing Reserved Instances across multiple database services, Dynata consolidated their cost commitment into a single, flexible pricing model. This reduced operational overhead by 70%, extended cost coverage to Amazon Aurora serverless, and lowered total cost of ownership as their infrastructure evolved.
PostgreSQL provides several planner configuration parameters, such as enable_seqscan and enable_indexscan, that influence how execution plans are generated. These settings affect planning, not the execution of an already-generated plan. With prepared statements, this raises an interesting question. Should planner settings be applied before PREPARE, before EXECUTE, or both?
Let's look at a simple example: a "tasks" table with a due date and a "done" status:
\cdroptableifexiststasks;-- a table of tasks with status (done or not) and due datecreatetabletasks(idbigintgeneratedalwaysasidentityprimarykey,duetimestamptz,doneboolean);-- insert 500 tasks, with 1% not doneinsertintotasks(due,done)selectnow()+interval'1 day'*n,42!=n%100fromgenerate_series(1,500)n;-- index the todo (partial index)createindexontasks(due,id)wheredone=false;vacuumanalyzetasks;
With a partial index, I indexed only the tasks that are not yet done (done = false) because that's my most frequent query pattern:
With partial indexes, the condition covered by the index is not even visible in the execution plan because the index itself enforces the condition.
Prepared statement
I decided to use a prepared statement with all values as parameters. It is probably not a good idea in this case. When a parameter can have only a few different values and you expect different cardinalities for each, you should probably define one query per value, using literals. I'm doing this to illustrate what can happen, with a simple, extreme example:
I was still using the same prepared statement, yet the execution plan had changed. PostgreSQL generated a new custom plan for this execution, so it used the planner setting active at EXECUTE time (enable_indexscan = 'off') rather than any setting active when the statement was prepared.
With plan_cache_mode set to the default auto, each execution of the prepared statement goes through the planning phase like a non-prepared statement for at least the first five executions, so the query planner parameters at EXECUTE time drive the planning. The result is a custom plan. After five executions, it may switch to a generic plan if the optimizer estimates it's worth it, comparing the generic plan's cost with the average execution cost of the previous custom plans (including planning overhead).
Because this decision depends on cost estimates, previous execution history, and the generic-versus-custom plan heuristic, plan selection may be less predictable than when plan_cache_mode is explicitly set. I recommend not relying on the auto behavior and instead deciding whether a prepared statement should be generic or custom by setting plan_cache_mode accordingly. Ideally, you should use parameters only when a generic plan is acceptable, and use different prepared statements with literals when the value matters for choosing the optimal access path.
Generic plan
You might expect a generic plan to permanently preserve the planner environment that was in effect at the time the plan was created. Here is the same example with plan_cache_mode set to force_generic_plan:
--- reset the session postgres=#\cYouarenowconnectedtodatabase"postgres"asuser"postgres".postgres=#\dconfigenable*scan*ListofconfigurationparametersParameter|Value----------------------+-------enable_bitmapscan|onenable_indexonlyscan|onenable_indexscan|onenable_seqscan|onenable_tidscan|on(5rows)-- disable auto plan cache mode and set it to genericpostgres=#setplan_cache_mode=force_generic_plan;SETpostgres=#preparec(boolean,int)asselectid,due,donefromtaskswheredone=$1andid>0orderbyduelimit$2;PREPAREpostgres=#explain(analyze,settings)executec(false,1);QUERYPLAN------------------------------------------------------------------------------------------------------------------Limit(cost=21.46..21.52rows=25width=17)(actualtime=0.057..0.057rows=1.00loops=1)Buffers:sharedhit=7->Sort(cost=21.46..22.08rows=250width=17)(actualtime=0.055..0.055rows=1.00loops=1)SortKey:dueSortMethod:top-NheapsortMemory:25kBBuffers:sharedhit=7->SeqScanontasks(cost=0.00..11.50rows=250width=17)(actualtime=0.010..0.040rows=5.00loops=1)Filter:((id>0)AND(done=$1))RowsRemovedbyFilter:495Buffers:sharedhit=4Settings:plan_cache_mode='force_generic_plan'Planning:Buffers:sharedhit=122PlanningTime:0.453msExecutionTime:0.073ms(15rows)
With the predicate on "done" that can take any value, a generic plan cannot use the partial index that contains entries only for the true value, so the query planner falls back to a Seq Scan.
The statement was not re-planned. While there's no direct proof, several clues suggest it:
Seq Scan persisted even when disabled, even though an alternative access method, such as using the primary key index, is available and would respect the directive, even if not optimal.
The Planning section was absent, as seen in the initial EXECUTE after PREPARE, which showed Buffers: shared hit related to catalog lookups.
The Planning Time was brief, only covering the time to retrieve the plan from cache.
There was no Disabled: true indicator or a very high cost noted in earlier PostgreSQL versions for Seq Scan, indicating that enable_seqscan = 'off' was ineffective in this case.
A potential source of confusion is that enable_seqscan = 'off' appears in the Settings section, even though it was not used to produce the displayed plan. The Settings section shows planner-related GUC values active during EXPLAIN execution, which may differ from those active when a cached generic plan was created.
DDL invalidation
Prepared statements continue to use cached plans when query parameters change, but generic plans might be re-created if invalidated. DDL statements, such as adding a column, invalidate cached plans regardless of whether they depended on that column:
This time, enable_seqscan = 'off' was used because the prepared statement was re-planned, which effectively skipped Seq Scan in favor of a Bitmap Heap Scan.
Because the plan is generic, PostgreSQL cannot assume that $1 will always meet the partial index's predicate. Therefore, the partial index cannot be used, but the primary key index "tasks_pkey" contains entries for all rows and can be used when sequential scan is disabled.
Disabled: true (PostgreSQL 18)
I've run another DDL to remove the primary key and, consequently, the index, and the prepared statement is re-planned:
The mention of Disabled: true indicates that the disabled node was still in use due to the absence of an alternative. The only index on this table is a partial index, which can be used only with a custom plan when parameter $1 is false, not with a standard plan.
In PostgreSQL 17, instead of Disabled: true, you would see an extremely high cost, indicating that disabled scans are deprioritized:
It's important to determine if the EXECUTE-time parameters were applied to the plan, as the Settings section can be misleading. It displays the parameters set during the execution explanation, but they only affected the plan if a re-planning occurred. EXPLAIN does not reveal the parameters that established the cached plan.
In an upcoming blog post about pg_plan_advice, I'll share a different approach to guide PostgreSQL 19's query planner. We will see that changing pg_plan_advice.advice keeps behavior consistent because it doesn't invalidate cached plans, as we've observed with enable_seqscan. However, using EXPLAIN (plan_advice) shows the hints used during planning, which differ slightly from the settings output.
EXECUTE is doing the planning
These examples clearly show that the important planner settings are those active when PostgreSQL creates a plan, not necessarily when ... (truncated)
Real-time analytics means queries that return fresh results in milliseconds over continuously arriving data, not pre-computed reports refreshed on a schedule. ClickHouse is built for this workload: columnar storage, vectorized execution, streaming ingestion, and materialized views that pre-aggregate on write.
Monitoring systems generate continuous streams of metrics, logs, and traces that need to be ingested, stored, queried, and alerted on in real time. ClickHouse handles the volume and query latency that monitoring workloads require, from infrastructure metrics to application logs to security event correlation.
Postgres and MariaDB are both open-source relational databases, but they diverged from different lineages and optimize for different workloads. MariaDB forked from MySQL. Postgres evolved independently with a richer type system and extension ecosystem. The choice depends on your SQL needs, replication requirements, and analytics strategy.
Postgres and Oracle Database are both mature relational databases capable of handling enterprise OLTP workloads, but they make fundamentally different trade-offs on licensing, ecosystem, SQL dialect, and analytics. The choice depends on your budget, existing stack, and where your data needs to go next.
The main challenge in document databases is the flexible nature of fields: the same path can be a scalar, an array, nested, or missing. Despite this, an index must specify what it covers and the order in which rows can be produced. B-tree indexes work well for fixed-scalar columns, enabling prefix filtering and returning sorted rows. GIN and RUM inverted indexes support flexible, repeated values, but traditional RUM ordering relies on distance operators on attached values rather than standard document-style ORDER BY field LIMIT n.
DocumentDB's Extended RUM closes that gap. It extends the RUM access method for compound document indexes by generating composite index terms from the indexed paths and applying an ordering transform during the scan. The result is an inverted, multikey-style index that can filter, sort, and stop at LIMIT in a single Index Scan, while preserving document semantics for arrays and missing fields.
The RUM index supports filtering and ordering by distance:
--EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON) SELECTid,published,published<=>'1970-01-01'::timestampas" <=> 1970",extract(epochfrompublished)as" epoch "FROMarticlesWHEREtsv@@to_tsquery('english','postgresql')ANDcategory='tech'ORDERBYpublished<=>'1970-06-01'::timestampLIMIT5;id|published|<=>1970|epoch-----+---------------------+------------+-------------------20|2020-01-0120:00:00|1577908800|1577908800.00000040|2020-01-0216:00:00|1577980800|1577980800.00000060|2020-01-0312:00:00|1578052800|1578052800.00000080|2020-01-0408:00:00|1578124800|1578124800.000000100|2020-01-0504:00:00|1578196800|1578196800.000000(5rows)QUERYPLAN------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------Limit(actualtime=70.167..70.177rows=5loops=1)Output:id,published,((published<=>'1970-01-01 00:00:00'::timestampwithouttimezone)),(EXTRACT(epochFROMpublished)),((published<=>'1970-06-01 00:00:00'::timestampwithouttimezone))Buffers:sharedhit=223,tempread=550written=550->IndexScanusingidx_rum_multionpublic.articles(actualtime=70.165..70.175rows=5loops=1)Output:id,published,(published<=>'1970-01-01 00:00:00'::timestampwithouttimezone),EXTRACT(epochFROMpublished),(published<=>'1970-06-01 00:00:00'::timestampwithouttimezone)IndexCond:((articles.tsv@@'''postgresql'''::tsquery)AND(articles.category='tech'::text))OrderBy:(articles.published<=>'1970-06-01 00:00:00'::timestampwithouttimezone)Buffers:sharedhit=223,tempread=550written=550Planning:Buffers:sharedhit=2PlanningTime:0.124msExecutionTime:70.698ms(12rows)
Although Order By is integrated into the Index Scan, temp read reveals that it isn't a straightforward ordered index traversal, unlike a B-tree. Internal RUM scan processes spilled over to temporary storage. The key point is that there's no PostgreSQL Sort node involved. However, this is still distance-based ordering rather than simple key ordering.
I used the distance operator <=> with a date earlier than any date in this table, so the query effectively retrieves the first five articles sorted by published date. RUM allows ordering by its distance operators on attached values, such as published <=> constant. This can resemble chronological ordering when the constant is outside the data range, but it isn't the same as a simple ORDER BY published.
If I use a basic ORDER BY in my query without applying the distance operator, I obtain the same result, but it takes longer to execute:
--EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON) SELECTid,published,published<=>'1970-01-01'::timestampas" <=> 1970",extract(epochfrompublished)as" epoch "FROMarticlesWHEREtsv@@to_tsquery('english','postgresql')ANDcategory='tech'ORDERBYpublishedLIMIT5;id|published|<=>1970|epoch-----+---------------------+------------+-------------------20|2020-01-0120:00:00|1577908800|1577908800.00000040|2020-01-0216:00:00|1577980800|1577980800.00000060|2020-01-0312:00:00|1578052800|1578052800.00000080|2020-01-0408:00:00|1578124800|1578124800.000000100|2020-01-0504:00:00|1578196800|1578196800.000000(5rows)QUERYPLAN----------------------------------------------------------------------------------------------------------------------------------------Limit(actualtime=21870.366..21870.368rows=5loops=1)Output:id,published,((published<=>'1970-01-01 00:00:00'::timestampwithouttimezone)),(EXTRACT(epochFROMpublished))Buffers:sharedhit=3read=50215written=1->Sort(actualtime=21870.364..21870.366rows=5loops=1)Output:id,published,((published<=>'1970-01-01 00:00:00'::timestampwithouttimezone)),(EXTRACT(epochFROMpublished))SortKey:articles.publishedSortMethod:top-NheapsortMemory:25kBBuffers:sharedhit=3read=50215written=1->BitmapHeapScanonpublic.articles(actualtime=85.266..21852.624rows=50000loops=1)Output:id,published,(published<=>'1970-01-01 00:00:00'::timestampwithouttimezone),EXTRACT(epochFROMpublished)RecheckCond:((articles.tsv@@'''postgresql'''::tsquery)AND(articles.category='tech'::text))HeapBlocks:exact=50000Buffers:sharedhit=3read=50215written=1->BitmapIndexScanonidx_rum_multi(actualtime=75.951..75.951rows=50000loops=1)IndexCond:((articles.tsv@@'''postgresql'''::tsquery)AND(articles.category='tech'::text))Buffers:sharedhit=3read=215Planning:Buffers:sharedread=2PlanningTime:2.276msExecutionTime:21870.406ms(20rows)
RUM's ordering mechanism uses the <=> distance operator, which measures distance from a reference point. This differs from simply using ORDER BY published. When you directly apply ORDER BY published, RUM defaults to a bitmap scan combined with sorting.
For a query involving 1 million articles filtered by words = 'postgresql' and category = 'tech' (which yields 50K matches here) and sorted by published, it reads all matching rows and sorts them, taking 21 seconds because it reads 50K heap blocks instead of stopping after finding just 5 rows like expected with ORDER BY ... LIMIT.
In tables with a strict schema and no arrays, the solution is straightforward. B-tree indexes store entries in sorted order, enabling efficient filtering and retrieval. A composite B-tree index on (category, published) can filter for category='tech' and return results already ordered by published, eliminating the need for a separate sort step:
This is fast because the (category, published) B-tree walks category='tech' entries in timestamp order, and the filter predicate happens to reject only 20 rows before finding 5 matches. If the text predicate were much more selective or poorly correlated with published, this plan could also scan many rows.
B-trees index only columns with well-typed scalar values. A B-tree can index extracted scalar expressions from JSONB, but it does not naturally support document-database multikey semantics, where the same path may be scalar, an array, nested, or absent. For that, you need an inverted/multikey-style index. Here, I skipped indexing "words" because I know it can contain an array, and it relies on the fact that "category" can contain only one value. This is true in SQL, where the schema is declared for the table, but not for a polymorphic document collection.
To show the same access pattern with flexible documents, I use the DocumentDB extension for PostgreSQL. I create a collection to store the same data in a flexible schema:
This is similar to the "articles" table and index, but in a collection where the data type and cardinality don't have to be declared in advance. The index definition doesn't need to know that "words" contains an array and that there's only one "category" per article.
This is extremely fast, reading only 14 buffers, including additional lookups inside find_cursor_first_page, and is as efficient as the B-tree index on the SQL table, but on a flexible document collection.
Extended RUM is an extension of the RUM access method that shares the same on-disk page layout but overrides the scan, ordering, and cost-estimation entry points. The key addition is the ordered composite index, which matches the features of a multi-key index in MongoDB.
The find_cursor_first_page function executes everything inside a C function, so PostgreSQL's EXPLAIN only sees it as a black-box Function Scan node. To see the internal plan, I use bson_aggregation_pipeline, which generates inline SQL that the planner can optimize and expose:
In this post, you will learn how CRED built an automated orchestration framework around Amazon RDS blue/green deployments. The framework performs engine upgrades, instance scaling, storage optimization, and Change Data Capture (CDC) pipeline migration across their entire fleet. This approach achieved zero data loss incidents and zero production incidents.
When you’re running MongoDB in production, backups are non-negotiable. But not all backup strategies are equal. The gap between a good backup strategy and a bad one only becomes visible at the worst possible moment: when you actually need to restore. Many teams reach for the most familiar tools first: Volume snapshots or mongodump/mongorestore + … Continued
Upstream MySQL published an out-of-schedule release this week with two high-severity CVE fixes. If you’re running Percona Server for MySQL 5.7 or 8.0 under Extended Lifecycle Support (ELS), the program we previously called Post EOL Support, you don’t have to do anything to qualify for them. We’ve already applied the fixes and re-released the affected … Continued
As part of building the MySQL Community, we are going to publish a curated overview of MySQL and database news and events that you might have missed over the last period.
If you want to get these updates, just subscribe to the blog.
In the previous post, I executed a query that benefits from Asynchronous Sequential Scan. Although the OS-level read calls remain synchronous (pread64()), PostgreSQL's IO workers issue them and manage the asynchronous IO queues. Linux provides asynchronous buffered I/O that PostgreSQL can use directly via the io_uring system calls.
In this post, I run the same query using the io_uring IO method instead of the worker. Because I am running inside a Docker container where Secure Computing Mode (seccomp) disables io_uring system calls, I started a container with seccomp disabled:
This is similar to the previous post, but with a different io_method. I will execute the same query that benefits from io_combine, not the one involving large TOASTed documents:
This plan is similar to the previous one because io combine, visible as prefetch, works the same for both the worker and io_uring. The difference now is that I no longer see any postgres: io worker processes, since this is managed by the kernel.
I used strace on the PostgreSQL backend and on parallel workers:
The syscall is io_uring_enter(fd, to_submit, min_complete, flags, sig, sigsz), so:
io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 indicates: Kernel, here is one new I/O request from my submission queue. I do not want to wait. The kernel confirms that one submission was consumed.
io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 indicates: I am not submitting anything. Wait until at least one completion is available. The return value is zero because no new submissions were made by this call. The short elapsed time shows that the completion was available quickly.
The io_uring trace reveals that PostgreSQL does not wait for individual read operations. Instead, the backend consistently submits requests via io_uring_enter(..., 1, 0, ...) and retrieves completed requests from the completion queue with io_uring_enter(..., 0, 1, IORING_ENTER_GETEVENTS, ...). Most completions occur immediately, suggesting that the read stream maintains sufficient I/O activity to make results available when needed. This behavior aligns with the EXPLAIN statistics, which show a deep prefetch queue, large combined reads, and minimal waiting despite thousands of I/O operations.
The difference from the worker implementation is not what PostgreSQL reads, but how those reads are submitted and completed:
io_mode=worker
io_mode=io_uring
postgres backend
postgres backend
. -> postgres: io worker
. -> io_uring_enter()
. -> pread64()
. -> kernel
. -> kernel
I have run the same with five parallel PostgreSQL query workers:
On average, each process has 5 I/O operations in progress, for a total of 30. As execution continues, the load average rises because uninterruptible waits are counted alongside runnable tasks:
top - 15:32:43 up 23 days, 40 min, 1 user, load average: 25.11, 10.84, 4.17
Threads: 1031 total, 1 running, 1030 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.4 us, 1.5 sy, 0.0 ni, 0.0 id, 95.3 wa, 0.1 hi, 0.7 si, 0.0 st
GiB Mem : 23.6 total, 14.9 free, 5.9 used, 2.8 buff/cache
GiB Swap: 4.0 total, 3.8 free, 0.2 used. 14.0 avail Mem
PID USER VIRT S %CPU %MEM TIME+ COMMAND WCHAN
2187030 root 0.0m I 1.3 0.0 0:04.41 [kworker/u8:4-iscsi_q_1] -
2211481 opc 221.9m R 1.3 0.0 0:02.65 top -
2143284 100998 251.3m S 1.0 0.7 0:47.66 postgres: postgres postgres 10.0.2.100(35862) EXPLAIN arm64_sys+
2212667 100998 247.1m S 1.0 0.2 0:00.38 postgres: parallel worker for PID 73 arm64_sys+
2212669 100998 247.1m S 1.0 0.1 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212670 100998 247.1m S 1.0 0.1 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212671 100998 247.1m S 1.0 0.2 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212668 100998 247.1m S 0.7 0.2 0:00.40 postgres: parallel worker for PID 73 arm64_sys+
2212257 100998 251.3m D 0.3 0.7 0:00.07 postgres: postgres postgres 10.0.2.100(35862) EXPLAIN generic_f+
2210102 root 0.0m I 0.3 0.0 0:01.36 [kworker/u8:1-xfs-cil/sdb] -
2212682 100998 247.1m D 0.3 0.2 0:00.02 postgres: parallel worker for PID 73 generic_f+
2212694 100998 247.1m D 0.3 0.2 0:00.02 postgres: parallel worker for PID 73 generic_f+
2212794 100998 247.1m D 0.3 0.2 0:00.01 postgres: parallel worker for PID 73 generic_f+
4157944 opc 23.1m D 0.3 0.1 18:26.08 /usr/bin/fuse-overlayfs -olowerdir=/data/opc/share/containers/storage/overlay/l/JU5OB2S2NJVEHXCBJJ2RUU7R4M:/data/opc/share/containers/storage/overlay/l/SCFJOZKVJCNPWIHHO5L+ wait_on_p+
1 root 380.1m S 0.0 0.1 7:11.80 /usr/lib/systemd/systemd --switched-root--system--deserialize 18 -
The behavior with io_uring is subtler than with the worker method. With synchronous pread64(), the calling process blocks until the read completes and may enter uninterruptible sleep (D state) during the I/O. Here is top when running io_method=worker and max_parallel_workers_per_gather = 5:
top - 18:06:57 up 23 days, 3:14, 1 user, load average: 8.15, 8.05, 4.48
Threads: 1014 total, 1 running, 1013 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.3 us, 3.3 sy, 0.0 ni, 3.5 id, 89.8 wa, 0.2 hi, 0.9 si, 0.0 st
MiB Mem : 24132.3 total, 16070.1 free, 6064.6 used, 1997.6 buff/cache
MiB Swap: 4095.9 total, 3878.6 free, 217.3 used. 14712.0 avail Mem
PID USER VIRT S %CPU %MEM TIME+ COMMAND
2221405 opc 227264 R 1.3 0.0 1:57.53 top
2291054 100998 234816 S 1.3 0.1 0:00.23 postgres: parallel worker for PID 75
2291057 100998 234816 S 1.3 0.1 0:00.23 postgres: parallel worker for PID 75
2280024 root 0 I 1.0 0.0 0:05.86 [kworker/u8:1-iscsi_q_1]
2287675 100998 235968 S 1.0 0.7 0:06.82 postgres: postgres postgres 10.0.2.100(39936) EXPLAIN
1680775 opc 24896 D 0.7 0.1 59:06.99 /usr/bin/fuse-overlayfs -olowerdir=/data/opc/share/containers/storage/overlay/l/JU5OB2S2NJVEHXCBJJ2RUU7R4M:/data/opc/share/containers/storage/overlay/l/SCFJOZKV+
2265340 root 0 I 0.7 0.0 0:09.13 [kworker/u8:0-iscsi_q_1]
2291053 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2291055 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2291056 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2287499 100998 231808 D 0.3 0.6 0:00.74 postgres: io worker 0
2287500 100998 231808 D 0.3 0.5 0:00.53 postgres: io worker 1
2288950 100998 231808 D 0.3 0.5 0:00.54 postgres: io worker 2
2288953 100998 231808 D 0.3 0.5 0:00.41 postgres: io worker 3
2288954 100998 231808 D 0.3 0.5 0:00.44 postgres: io worker 4
2288955 100998 231808 D 0.3 0.5 0:00.39 postgres: io worker 5
With io_uring, PostgreSQL submits requests via io_uring_enter() and can continue processing while those reads are in flight. It only waits when it needs completions that are not yet available.
When I increased the query to five parallel workers, the system's load average rose above 25, even though the CPUs were almost idle:
If this load average were mostly due to runnable processes competing for the CPU, the CPUs would be busy. They are not: only about 4% of CPU time is spent running user or kernel code, while most time is spent waiting on I/O. On Linux, load average includes both runnable tasks (R) and tasks in uninterruptible sleep (D). This combination of a high load average, mostly idle CPUs, and dominant I/O wait suggests that the bottleneck is storage performance, not CPU capacity. Some waits appear as D-state tasks at the top, but others may be too short to capture in a snapshot yet still contribute to the scheduler's load accounting.
When systems start using io_uring, system administrators will need to keep an eye on things: a high load average without noticeable R or D states can be tricky to analyze.
At the PostgreSQL level, the IO wait class reflects different wait events for asynchronous IO. When using io_submit=worker, the backend waits on the io worker to complete with AioIoCompletion:
With io_submit=io_uring, the backend waits first for IO submission with AioIoSubmission, which is quick, and then for IO execution with AioIoCompletion:
To illustrate the traditional synchronous IO waits, DataFileRead, I executed the select on the TOASTed table from the previous blog post:
This serves as a reminder that asynchronous IO isn't always feasible.
Conclusion
PostgreSQL 19's asynchronous I/O is not about reading different table blocks. The same sequential scan is performed. For a large table, blocks are read from the buffer manager via the sequential-scan ring buffer, so the scan avoids flooding the entire shared buffer pool.
The difference is in how waiting is organized.
With io_method=worker, PostgreSQL backends delegate read requests to dedicated I/O worker processes. These workers issue synchronous pread64() calls, and a worker process can block while the kernel completes each read.
With io_method=io_uring, PostgreSQL submits requests directly to the kernel via the io_uring submission queue. The kernel reports completed operations via the completion queue. PostgreSQL can therefore keep multiple reads in flight and usually consume completions as soon as they become available. If completions are not available when requested, the backend can still wait.
io_combine is independent of that choice. It still combines nearby block reads into larger I/O operations. The io_method determines how those operations are submitted and completed: either via PostgreSQL I/O workers using synchronous pread64(), or via kernel-managed asynchronous I/O using io_uring.
The execution plans, traces, and system metrics tell the same story. PostgreSQL is not eliminating I/O waits. Instead, it hides much of that latency by combining reads, maintaining a deep prefetch pipeline, and keeping enough outstanding requests so that completions are often ready when the executor needs them. This approach works for operations where PostgreSQL can predict future page accesses, such as Sequential Scan, Bitmap Heap Scan, and Vacuum.
This post shows you how to set up centralized cross-account and cross-Region monitoring for Amazon Relational Database Service (Amazon RDS) and Amazon Aurora databases using Amazon CloudWatch Database Insights. Whether your databases are spread across two AWS accounts or ten, and across one Region or several, this walkthrough gives you a single monitoring account with visibility across your entire database fleet.
PostgreSQL scans operate at the page level: the buffer manager fetches one 8 KB page (BLCKSZ) at a time, issuing one read per block. The operating system may merge some of these requests through readahead, but PostgreSQL still generates many small I/O operations, leading to a high number of system calls on large scans. This is inefficient for streaming access patterns.
Operations like Seq Scan and Bitmap Heap Scan know which blocks they need ahead of time and can read them independently, unlike Index Scans where each next block depends on the previous one.
PostgreSQL 19 changes this with the new read stream layer. Instead of issuing one read per page, it groups adjacent blocks and combines them into larger I/O requests, up to io_combine_limit. The logical unit remains the 8 KB page, but physical I/O is no longer page-by-page. This reduces system call overhead and makes better use of modern storage.
IO combining and prefetch
PostgreSQL 19 (currently in beta) introduces Asynchronous I/O (AIO), enabling non-blocking reads for operations involving multiple blocks. Instead of waiting for each read to finish before issuing the next, PostgreSQL can pipeline I/O requests using methods such as worker threads, io_uring, or a synchronous fallback. The AIO read pathway creates a look-ahead stream of block requests, grouping nearby blocks into larger I/O operations. This process attempts to coalesce adjacent blocks into a single request, subject to the io_combine_limit.
Prefetch or read-ahead still involves requesting blocks before they are needed, but with AIO, this is now integrated with asynchronous submission and batched reads, reducing reliance on implicit operating system readahead by issuing explicit asynchronous and batched reads. These improvements can be seen with EXPLAIN (ANALYZE, IO), which provides detailed I/O statistics.
PostgreSQL 19 (beta)
If you want to test the beta of PostgreSQL 19, here is how to start a container that exposes port 5432:
docker run -d--name pg19 \-d-p 5432:5432 \-ePOSTGRES_PASSWORD=xxx \
postgres:19beta1 postgres
If you read this later, use the release candidate or the final release.
AIO configuration
I connect with PGUSER=postgres PGPASSWORD=xxx PGHOST=localhost psql and check the IO configuration:
You may have heard about io_uring, a Linux I/O interface that provides true asynchronous I/O without requiring Direct I/O, unlike the legacy AIO interface. It’s not available everywhere, and I can’t use it from Docker here, but the worker method still enables concurrent reads and some I/O combining. It's the default in PG19.
With this configuration, two workers can combine up to 128kB of IO reads, which is 16 blocks, since the block size is 8KB.
Seq Scan on small rows table (inline)
I create a "smalldocs" table and load it with 1,024,000 rows, each with a random 1KB text in the "data" column:
This plan shows a parallel sequential scan efficiently scanning the 1GB table using PostgreSQL’s AIO read path. Each of the 3 parallel processes (leader + 2 workers) scans part of the table, and the read stream keeps a large look‑ahead (about 35 blocks on average), so data is requested well before it is needed. Those blocks are grouped into larger I/O requests (around 16 blocks per read, about 128KB), which reduces overhead.
Because reads are submitted in advance, almost all I/O completes asynchronously: only 19 waits out of more than 8 thousands requests. At any time, a few I/Os are in flight (around 3), keeping the storage busy.
In short, this is an ideal case for AIO: sequential access enables deep prefetching, combined reads, and very few stalls, so the scan runs close to I/O throughput limits rather than being blocked on individual reads.
Those reads can be traced with strace, they are pread64 calls from the postgres: io worker processes:
Here, strace shows PostgreSQL I/O workers issuing pread64 calls on the table file, with most reads at 131072 bytes (128KB), corresponding to 16 PostgreSQL pages. This confirms that sequential scan uses I/O combining, grouping multiple 8KB blocks into larger reads. Multiple pread64 calls are marked as and later resumed, showing that reads are in flight concurrently. This matches the AIO model: requests are submitted ahead of time, and completion is picked up later, rather than waiting for each read. Occasional smaller reads (8KB, 16KB, 32KB) appear at boundaries or when combining is not possible, but the dominant pattern is large, aligned reads. Overall, the trace confirms what EXPLAIN reports:
reads are combined into larger I/O (about 128KB)
multiple I/Os are issued in parallel (pipelining)
backend rarely waits, as I/O completes asynchronously
This is a direct observation of PostgreSQL AIO read streams: look‑ahead + I/O combining + concurrent execution, achieving high throughput on sequential scans.
Seq Scan on oversized rows table (TOASTed)
I create another similar table, "largedocs", and load it with fewer and larger rows. My goal is to show what happens when TOAST kicks in with large extended data types. I load 1000 rows, each with a random 1MB text in the "data" column:
Here, the sequential scan looks trivial, but most of the work is not in the main table. Only 1000 rows are scanned, and they are small (just TOAST pointers), so the Seq Scan itself does almost no I/O: only 8 blocks are read, with no parallelism and almost no prefetching (avg=1.88).
However, execution time is much higher (2.7s) because each row requires fetching the TOASTed value to compute length(data). The access pattern is no longer sequential. Instead of scanning a contiguous stream of blocks, PostgreSQL performs a separate lookup into the TOAST table for each row.
Those reads are effectively random, so there is no opportunity for read‑ahead or I/O combining. The AIO read stream cannot build a pipeline, and PostgreSQL falls back to small reads driven by the executor, one TOAST value at a time.
I've left my strace running, and it shows only the four reads going to the IO workers (I used pgrep -f "postgres: io worker"), between 1 and 4 blocks (8kb and 32kb):
The strace confirms this behavior. The I/O workers only handle a few reads on the main table, between 1 and 4 blocks (8KB to 32KB), which explains why almost nothing shows up there.
I can check that the relation base/5/16397 is the table "largedocs":
When tracing all PostgreSQL backends (using pgrep -f "postgres: " processes), the actual workload appears: a large number of 8KB pread64 calls on the TOAST table (base/5/16402). These reads are small, scattered, and not combined:
This is the opposite of the previous example. With small rows, the sequential scan becomes a true streaming workload, where AIO can prefetch and combine I/O efficiently. With large TOASTed values, the same sequential scan degenerates into many random lookups, in which prefetching and I/O combining are ineffective, and AIO offers little benefit.
eBPF (block layer)
At the syscall level, we saw how PostgreSQL issues fewer, larger reads, which reduce context switches. To see what actually reaches the storage device, we need to look at a lower layer.
To observe what actually reaches the storage device, I traced block I/O requests with eBPF. Because this runs at the block layer, it doesn’t show PostgreSQL logical reads, but it does show I/O requests after the filesystem, page cache, readahead, and request merging. First, I clear the cache to make sure reads hit the device, then I trace block requests and aggregate their sizes:
On the sequential scan of smalldocs, the distribution shows a wide range of request sizes. Large requests like 256KB, 512KB, or even 1MB appear frequently:
On largedocs, with TOASTed values that are read by PostgreSQL with 8kB reads, smaller sizes are more visible, but surprisingly large requests still appear at block level:
This is because we are no longer looking at what PostgreSQL requests, but at what reaches the storage after the OS stack has optimized it, and because my TOAST chunks, inserted in bulk, are contiguous. The filesystem performs read-ahead, and the kernel can merge adjacent requests, producing larger I/O operations than those issued by PostgreSQL.
Importantly, this still uses buffered I/O through the filesystem cache. With Direct I/O, such merging would be much more limited, and request sizes would more closely reflect what the database issues.
This explains why both workloads can show similar block‑level patterns, when the blocks read are contiguous.
Even when the block layer ends up issuing similar I/O sizes after merging, the syscall pattern still matters: fewer large reads mean fewer syscalls and fewer context switches, while many small reads increase CPU overhead.
In short, strace shows what PostgreSQL requests, while eBPF shows what actually reaches the device.
Conclusion
This highlights a simple rule: AIO helps when PostgreSQL can see and exploit a sequential access pattern. With many small rows, a Seq Scan becomes a streaming workload where the read stream can prefetch ahead, combine blocks into larger I/O, and pipeline requests efficiently.
However, With large TOASTed values, the same scan turns into thousands of small, random lookups, where there is no locality to exploit: no effective prefetching, no I/O combining, and almost no benefit from AIO.
To understand what is happening at each layer: EXPLAIN shows intent, strace shows requests, and eBPF shows what actually reaches the device.
In this post, you learn how to design and implement a user authentication service with session management on Amazon Aurora DSQL. You see the full request flow from client to database and back, explore the design considerations specific to Amazon Aurora DSQL, and discover practical lessons from building and testing against a live cluster.
PostgreSQL community images address a real gap in how a Kubernetes database operator earns your trust. Running a database operator on Kubernetes means trusting two things: the code, and the container images the operator pulls. The code is on GitHub, easy to inspect, easy to fork. The container images, the registry that hosts them, and the … Continued
Debugging applications in Kubernetes can be tricky. Containers are designed to be small, immutable, and purpose-built. That is great for production, but not always ideal when something breaks. Many production images are minimal or distroless. They may not include tools that are useful for troubleshooting. In some cases, the application container may already be crashing, … Continued
A few years ago, if there was a discussion on “Should we run databases on Kubernetes?”, there were more people saying no than yes. One of the common answers was, “No. Kubernetes is for stateless workloads. Keep your databases outside.” Thankfully, today the discussion is no longer about whether we should run databases on Kubernetes, … Continued
A new endpoint exposes ClickHouse's lightweight DELETE functionality in Tinybird: pick between a synchronous call that blocks until the delete is done, or an asynchronous one that you poll for partition progress.
You may have read about Hybrid search for Azure HorizonDB. It is presented as combining BM25 full‑text and vector similarity in a single query. But how are they actually combined? The execution plan answers that.
In this post, I use a small synthetic product catalog to ensure the entire demo is reproducible. The text is sufficiently realistic for BM25 queries, and the embeddings are deterministic synthetic vectors, allowing you to run the full script without needing an embedding model. If you have azure_openai.create_embeddings() configured, you can substitute the synthetic embedding function with actual embeddings.
I will test two separate queries, followed by three methods of combining BM25 with vector retrieval:
cascade (BM25 → vector)
cascade (vector → BM25)
hybrid (parallel + fusion)
These are not interchangeable; they represent distinct trade-offs between recall and performance.
Setup
This example uses:
pg_textsearch for BM25 full-text search
pgvector for the vector type and distance operator
pg_diskann for the vector index, when available
The extensions must be listed in azure.extensions to enable CREATE EXTENSION.
In addition, pg_textsearch must be loaded on startup:
I should use azure_openai.create_embeddings(), as I mentioned in the previous blog post. However, for this demo, I opted to create a fake embedding function that maps text to a simple vector(16) based on product keywords. This isn't an embedding model, but it ensures that execution plans are reproducible without relying on a model. It also simplifies the concept of embeddings with a basic, small-dimension LIKE-style semantic vector. The purpose is to demonstrate a search query using both a real text search and a vector-based semantic search.
Using a straightforward CASE that searches for specific words, I create the semantic vector for a text. This method employs vector similarity solely for demonstration, without utilizing a model. You can envision AI models doing the same but with thousands of dimensions driven by large language models (LLMs) rather than keywords.
Table with text and embeddings
I created a product catalog table with structured fields, text, and a single vector column:
I loaded a few hundred thousand rows. The data is synthetic but intentionally patterned: products have categories, materials, styles, and terms that are useful for both BM25 and vector search:
insertintoproducts(product_id,category,brand,price,title,description,embedding)withgeneratedas(selectgasproduct_id,(array['chair','table','sofa','lamp','desk','shelf'])[1+(g%6)]ascategory,(array['Contoso','Fabrikam','Northwind','AdventureWorks','Wingtip','Tailspin'])[1+(g%6)]asbrand,(array['mid-century modern','industrial','scandinavian','classic','minimalist','outdoor'])[1+(g%6)]asstyle,(array['walnut wood','black metal','oak wood','leather','fabric','brushed steel'])[1+((g/7)%6)]asmaterial,(25+(g%500))::numeric(10,2)aspricefromgenerate_series(1,200000)asg)selectproduct_id,category,brand,price,initcap(style||' '||material||' '||category)astitle,concat(style,' ',category,' by ',brand,' with ',material,'. Designed for ',casewhencategoryin('chair','sofa')then'living room seating'whencategoryin('table','desk')then'home office and dining'whencategory='lamp'then'warm interior lighting'else'storage and display'end,'. Product code ',product_id,'.')asdescription,demo_embedding(concat_ws(' ',style,material,category,brand))asembeddingfromgenerated;
I tested some searches on search_text and embedding and started indexing those columns.
Create the indexes (BM25 and DiskANN)
First, I created the BM25 full-text index. HorizonDB’s BM25 full-text search brings BM25 ranking into PostgreSQL without a separate Elasticsearch/OpenSearch Search service. It uses the open-source extension pg_textsearch:
I started with a keyword search, using to_bm25query() to define the BM25 query. Ranking uses BM25 and is performed with the <@> operator. Top-k queries use this operator in ORDER BY ... LIMIT.
BM25 is implemented as an index-backed operator that must be bound to a specific index. This is why prepared statements require explicitly naming the index:
postgres=>preparequery1(text,int)asselectp.product_id,p.title,p.category,p.brand,p.pricefromproductsporderbyp.search_text<@>to_bm25query($1,'products_bm25_idx')limit$2;postgres=>explain(analyze,buffers,verbose,costsoff)executequery1('mid century modern wooden chair',10);QUERYPLAN---------------------------------------------------------------------------------------------------------------------------------------------Limit(actualtime=0.268..0.442rows=10loops=1)Output:product_id,title,category,brand,price,((search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query))Buffers:sharedhit=582->IndexScanusingproducts_bm25_idxonhybrid_demo.productsp(actualtime=0.267..0.440rows=10loops=1)Output:product_id,title,category,brand,price,(search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query)OrderBy:(p.search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query)Buffers:sharedhit=582QueryIdentifier:-4837396746836655100Planning:Buffers:sharedhit=1PlanningTime:0.116msExecutionTime:0.460ms(12rows)
The Index Scan returns the Top-10 result ('rows=10') directly in ranking order (Order By).
Lexical retrieval is good for exact words, rare terms, product codes, and anything where the user expects the same token to appear in the document.
Query 2: ANN only
I further explored the semantic aspect by performing similarity search using the cosine distance operator (<=>) for vectors:
Upstream MySQL published an out-of-schedule release this week with two high-severity CVE fixes. We’ve pulled those fixes into our next builds and are skipping the two versions we had already queued: Percona Server for MySQL 8.4.9 and 9.7.0. These fixes arrived through Oracle’s new monthly Critical Security Patch Updates (CSPUs), which Oracle announced begin May … Continued
The term “HTAP” is the holy grail of database systems. It describes what every data engineer would
love: Being able to do all your data Processing, no matter if it’s complex Analytics or
fast-paced Transactional operations in a single Hybrid system.
Many system have tried to enable HTAP but have found that building a truly hybrid system is an
impossible challenge. The keynote talk at Databricks Data + AI Summit 2026 highlighted that it is
impossible to get a query on the analytical warehouse to execute in less than 1 second. As a new
solution that solves this, the Databricks cofounder Reynold Xin announced “Databricks LTAP” and
directly cited our paper on “Morsel-Driven Parallelism” as one of the “latest and coolest academic
papers”:
With their LTAP offering, Databricks is actually able to offer sub-second transactional performance
while maintaining its well-known analytical performance. That’s genuinely impressive! But if you
look under the hood (e.g. by watching Databricks engineers talk about the technology behind LTAP),
you will see that LTAP still uses the same classical separation between operational and analytical
data. By their own description, LTAP does not run on one engine. It keeps a transactional engine and
an analytical engine and unifies them at the storage layer. You can think of this being a really
good, really fast zero-ETL system.
Zero-ETL is not enough, though. As long as you run two engines, you have two sources of truth, and
there is a moment where data crosses from one to the other. Zero-ETL makes that gap small, but even
if the name suggests otherwise, it cannot make it zero. The data still has to move from the system
that wrote it to the system that reads it. When you really want HTAP, you really care about this gap
being actually zero. Take fraud detection: a warehouse can flag suspicious activity only after the
fact, but you want to catch it before the money moves. For zero read lag and a single source of
truth, you have to re-think the entire system around one engine that runs both workloads natively.
It’s almost impossible to change an existing system designed for either transactions or analytics
into a true hybrid system. Database researchers have known this for several decades already. We
weren’t the first to attempt it, and we didn’t coin the term HTAP. Systems like SAP
HANA and HyPer
went after it before us but required your data to fit completely in main memory. Sadly, this didn’t
work out. What made us reconsider that HTAP was back on
the table was that fast SSDs became widely available. So ten years ago, we started
Umbra, a research project with one goal: building a truly HTAP system.
CedarDB is built on that foundation.
Since then, new developments in the database space focused only on analytics, leading to great
analytical systems such as Databricks Lakehouse and Snowflake, and ClickHouse. It turns out the
existing transactional systems, even regular PostgreSQL, scaled to even the most demanding AI
workloads. What’s hard is making sure transactions
and analytics don’t slow each other down when running at the same time.
To make this work well, you need to unify both the execution engine and the storage format without
introducing new bottlenecks. For that, we built a hybrid column-row
format as our data layer. It can support fast writes on hot
data, automatically transforming between hot write-optimized and cold compressed data as needed,
fully transparently as a single copy.
Not only that, we also built the foundations for fast analytical processing on modern hardware. You
can find an overview of key techniques on our technology
page, including morsel-driven parallelism, data-centric code
generation, a cost-based optimizer with full subquery decorrelation, and a buffer manager designed
to fully utilize fast SSDs.
Databricks LTAP is coming soon, CedarDB is in production today! Good to see the industry catching up
to the problem. Come see what the answer looks like when it’s already running.