a curated list of database news from authoritative sources

July 05, 2026

PostgreSQL query planner parameters and prepared statements

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:

\c

drop   table if exists tasks;

-- a table of tasks with status (done or not) and due date
create table tasks (
 id   bigint generated always as identity primary key,
 due  timestamptz,
 done boolean
);

-- insert 500 tasks, with 1% not done
insert into tasks (due, done)
 select
  now()+interval '1 day'*n,
  42 != n%100
 from generate_series(1,500) n
;

-- index the todo (partial index)
create index on tasks(due,id)
 where done = false;

vacuum analyze tasks;

With a partial index, I indexed only the tasks that are not yet done (done = false) because that's my most frequent query pattern:

postgres=# explain
            select id, due, done from tasks 
             where done = false and id>0
             order by due limit 1
;

                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Limit  (cost=0.13..3.60 rows=1 width=17)
   ->  Index Scan using tasks_due_id_idx1 on tasks  (cost=0.13..17.47 rows=5 width=17)
         Index Cond: (id > 0)
(3 rows)

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:

postgres=# prepare c(boolean, int) as
            select id, due, done from tasks 
             where done = $1 and id>0
             order by due limit $2
;

PREPARE

postgres=# explain (analyze, settings) 
            execute c (false,  1)
;
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.13..3.60 rows=1 width=17) (actual time=0.087..0.088 rows=1.00 loops=1)
   Buffers: shared hit=4 read=1
   ->  Index Scan using tasks_due_id_idx on tasks  (cost=0.13..17.47 rows=5 width=17) (actual time=0.087..0.087 rows=1.00 loops=1)
         Index Cond: (id > 0)
         Index Searches: 1
         Buffers: shared hit=4 read=1
 Planning:
   Buffers: shared hit=3
 Planning Time: 0.148 ms
 Execution Time: 0.099 ms
(10 rows)


With the same prepared statement, I disabled the Index Scan before the next execution:

postgres=# set enable_indexscan to off;
SET

postgres=# explain (analyze, settings) 
            execute c (false,  1)
;

                                                   QUERY PLAN
----------------------------------------------------------------------------------------------------------------
 Limit  (cost=10.28..10.28 rows=1 width=17) (actual time=0.043..0.043 rows=1.00 loops=1)
   Buffers: shared hit=4
   ->  Sort  (cost=10.28..10.29 rows=5 width=17) (actual time=0.042..0.042 rows=1.00 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=4
         ->  Seq Scan on tasks  (cost=0.00..10.25 rows=5 width=17) (actual time=0.012..0.037 rows=5.00 loops=1)
               Filter: ((NOT done) AND (id > 0))
               Rows Removed by Filter: 495
               Buffers: shared hit=4
 Settings: enable_indexscan = 'off'
 Planning:
   Buffers: shared hit=12
 Planning Time: 0.174 ms
 Execution Time: 0.055 ms
(15 rows)

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=# \c
You are now connected to database "postgres" as user "postgres".

postgres=# \dconfig enable*scan*

List of configuration parameters

      Parameter       | Value
----------------------+-------
 enable_bitmapscan    | on
 enable_indexonlyscan | on
 enable_indexscan     | on
 enable_seqscan       | on
 enable_tidscan       | on
(5 rows)

-- disable auto plan cache mode and set it to generic

postgres=# set plan_cache_mode=force_generic_plan;

SET

postgres=# prepare c(boolean, int) as
            select id, due, done from tasks
             where done = $1 and id > 0
             order by due limit $2
;

PREPARE

postgres=# explain (analyze, settings)
            execute c (false,  1)
;

                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 Limit  (cost=21.46..21.52 rows=25 width=17) (actual time=0.057..0.057 rows=1.00 loops=1)
   Buffers: shared hit=7
   ->  Sort  (cost=21.46..22.08 rows=250 width=17) (actual time=0.055..0.055 rows=1.00 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=7
         ->  Seq Scan on tasks  (cost=0.00..11.50 rows=250 width=17) (actual time=0.010..0.040 rows=5.00 loops=1)
               Filter: ((id > 0) AND (done = $1))
               Rows Removed by Filter: 495
               Buffers: shared hit=4
 Settings: plan_cache_mode = 'force_generic_plan'
 Planning:
   Buffers: shared hit=122
 Planning Time: 0.453 ms
 Execution Time: 0.073 ms
(15 rows)

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.

I disable Seq Scan for the next execution:


postgres=# set enable_seqscan to off;

SET

postgres=# explain (analyze, settings)
            execute c (false,  1)
;
                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 Limit  (cost=21.46..21.52 rows=25 width=17) (actual time=0.059..0.060 rows=1.00 loops=1)
   Buffers: shared hit=4
   ->  Sort  (cost=21.46..22.08 rows=250 width=17) (actual time=0.057..0.057 rows=1.00 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=4
         ->  Seq Scan on tasks  (cost=0.00..11.50 rows=250 width=17) (actual time=0.018..0.051 rows=5.00 loops=1)
               Filter: ((id > 0) AND (done = $1))
               Rows Removed by Filter: 495
               Buffers: shared hit=4
 Settings: plan_cache_mode = 'force_generic_plan', enable_seqscan = 'off'
 Planning Time: 0.013 ms
 Execution Time: 0.080 ms
(13 rows)

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:

postgres=# alter table tasks add column description text;

ALTER TABLE

postgres=# explain (analyze, settings)
            execute c (false,  1)
;

                                                              QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=41.54..41.60 rows=25 width=17) (actual time=0.113..0.114 rows=1.00 loops=1)
   Buffers: shared hit=10
   ->  Sort  (cost=41.54..42.17 rows=250 width=17) (actual time=0.111..0.112 rows=1.00 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=10
         ->  Bitmap Heap Scan on tasks  (cost=20.09..31.59 rows=250 width=17) (actual time=0.069..0.105 rows=5.00 loops=1)
               Recheck Cond: (id > 0)
               Filter: (done = $1)
               Rows Removed by Filter: 495
               Heap Blocks: exact=4
               Buffers: shared hit=10
               ->  Bitmap Index Scan on tasks_pkey  (cost=0.00..20.02 rows=500 width=0) (actual time=0.050..0.050 rows=500.00 loops=1)
                     Index Cond: (id > 0)
                     Index Searches: 1
                     Buffers: shared hit=6
 Settings: plan_cache_mode = 'force_generic_plan', enable_seqscan = 'off'
 Planning:
   Buffers: shared hit=23
 Planning Time: 0.300 ms
 Execution Time: 0.130 ms
(21 rows)

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:

postgres=# alter table tasks drop constraint tasks_pkey;

ALTER TABLE

postgres=# explain (analyze, settings)
            execute c (false,  1)
;

                                                                                                        QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 Limit  (cost=21.46..21.52 rows=25 width=17) (actual time=0.054..0.055 rows=1.00 loops=1)
   Buffers: shared hit=4
   ->  Sort  (cost=21.46..22.08 rows=250 width=17) (actual time=0.052..0.052 rows=1.00 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=4
         ->  Seq Scan on tasks  (cost=0.00..11.50 rows=250 width=17) (actual time=0.014..0.046 rows=5.00 loops=1)
               Disabled: true
               Filter: ((id > 0) AND (done = $1))
               Rows Removed by Filter: 495
               Buffers: shared hit=4
 Settings: plan_cache_mode = 'force_generic_plan', enable_seqscan = 'off'
 Planning:
   Buffers: shared hit=16 dirtied=2
 Planning Time: 0.251 ms
 Execution Time: 0.069 ms
(16 rows)

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:

                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=10000000021.46..10000000021.52 rows=25 width=17) (actual time=0.144..0.145 rows=1 loops=1)
   ->  Sort  (cost=10000000021.46..10000000022.08 rows=250 width=17) (actual time=0.143..0.143 rows=1 loops=1)
         Sort Key: due
         Sort Method: top-N heapsort  Memory: 25kB
         ->  Seq Scan on tasks  (cost=10000000000.00..10000000011.50 rows=250 width=17) (actual time=0.098..0.136 rows=5 loops=1)
               Filter: ((id > 0) AND (done = $1))
               Rows Removed by Filter: 495
 Settings: plan_cache_mode = 'force_generic_plan', enable_seqscan = 'off', jit = 'off'
 Planning Time: 0.014 ms
 Execution Time: 0.167 ms
(10 rows)

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)

July 03, 2026

Extended RUM in DocumentDB: B-tree-like ordered scans for flexible BSON in PostgreSQL

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.

Here is the table I created for my previous blog post, RUM—Storing More in the Index:


postgres=# \d articles
                                                                   Table "public.articles"

  Column   |            Type             | Collation | Nullable |                                           Default                                         
-----------+-----------------------------+-----------+----------+---------------------------------------------------------------------------------------------
 id        | integer                     |           | not null | nextval('articles_id_seq'::regclass)
 title     | text                        |           | not null |
 body      | text                        |           | not null |
 category  | text                        |           | not null |
 published | timestamp without time zone |           | not null |
 score     | integer                     |           | not null |
 tsv       | tsvector                    |           |          | generated always as (to_tsvector('simple'::regconfig, (title || ' '::text) || body)) stored

Indexes:

    "articles_pkey" PRIMARY KEY, btree (id)
    "idx_gin_tsv" gin (tsv)
    "idx_rum_multi" rum (tsv rum_tsvector_addon_ops, category, published) WITH (attach=published, "to"=tsv)

The RUM index supports filtering and ordering by distance:


--EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON)  
SELECT id, published
 , published <=> '1970-01-01'::timestamp as " <=> 1970"
 , extract ( epoch from published )      as " epoch "
FROM articles
WHERE tsv @@ to_tsquery('english', 'postgresql')
  AND category = 'tech'
ORDER BY published <=> '1970-06-01'::timestamp
LIMIT 5
;

 id  |      published      |  <=> 1970  |       epoch
-----+---------------------+------------+-------------------
  20 | 2020-01-01 20:00:00 | 1577908800 | 1577908800.000000
  40 | 2020-01-02 16:00:00 | 1577980800 | 1577980800.000000
  60 | 2020-01-03 12:00:00 | 1578052800 | 1578052800.000000
  80 | 2020-01-04 08:00:00 | 1578124800 | 1578124800.000000
 100 | 2020-01-05 04:00:00 | 1578196800 | 1578196800.000000

(5 rows)

                                                                                                                                                                                                                                                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=70.167..70.177 rows=5 loops=1)
   Output: id, published, ((published <=> '1970-01-01 00:00:00'::timestamp without time zone)), (EXTRACT(epoch FROM published)), ((published <=> '1970-06-01 00:00:00'::timestamp without time zone))
   Buffers: shared hit=223, temp read=550 written=550
   ->  Index Scan using idx_rum_multi on public.articles (actual time=70.165..70.175 rows=5 loops=1)
         Output: id, published, (published <=> '1970-01-01 00:00:00'::timestamp without time zone), EXTRACT(epoch FROM published), (published <=> '1970-06-01 00:00:00'::timestamp without time zone)
         Index Cond: ((articles.tsv @@ '''postgresql'''::tsquery) AND (articles.category = 'tech'::text))
         Order By: (articles.published <=> '1970-06-01 00:00:00'::timestamp without time zone)
         Buffers: shared hit=223, temp read=550 written=550
 Planning:
   Buffers: shared hit=2
 Planning Time: 0.124 ms
 Execution Time: 70.698 ms

(12 rows)

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)  
SELECT id, published
 , published <=> '1970-01-01'::timestamp as " <=> 1970"
 , extract ( epoch from published )      as " epoch "
FROM articles
WHERE tsv @@ to_tsquery('english', 'postgresql')
  AND category = 'tech'
ORDER BY published
LIMIT 5
;

 id  |      published      |  <=> 1970  |       epoch
-----+---------------------+------------+-------------------
  20 | 2020-01-01 20:00:00 | 1577908800 | 1577908800.000000
  40 | 2020-01-02 16:00:00 | 1577980800 | 1577980800.000000
  60 | 2020-01-03 12:00:00 | 1578052800 | 1578052800.000000
  80 | 2020-01-04 08:00:00 | 1578124800 | 1578124800.000000
 100 | 2020-01-05 04:00:00 | 1578196800 | 1578196800.000000

(5 rows)

                                                               QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=21870.366..21870.368 rows=5 loops=1)
   Output: id, published, ((published <=> '1970-01-01 00:00:00'::timestamp without time zone)), (EXTRACT(epoch FROM published))
   Buffers: shared hit=3 read=50215 written=1
   ->  Sort (actual time=21870.364..21870.366 rows=5 loops=1)
         Output: id, published, ((published <=> '1970-01-01 00:00:00'::timestamp without time zone)), (EXTRACT(epoch FROM published))
         Sort Key: articles.published
         Sort Method: top-N heapsort  Memory: 25kB
         Buffers: shared hit=3 read=50215 written=1
         ->  Bitmap Heap Scan on public.articles (actual time=85.266..21852.624 rows=50000 loops=1)
               Output: id, published, (published <=> '1970-01-01 00:00:00'::timestamp without time zone), EXTRACT(epoch FROM published)
               Recheck Cond: ((articles.tsv @@ '''postgresql'''::tsquery) AND (articles.category = 'tech'::text))
               Heap Blocks: exact=50000
               Buffers: shared hit=3 read=50215 written=1
               ->  Bitmap Index Scan on idx_rum_multi (actual time=75.951..75.951 rows=50000 loops=1)
                     Index Cond: ((articles.tsv @@ '''postgresql'''::tsquery) AND (articles.category = 'tech'::text))
                     Buffers: shared hit=3 read=215
 Planning:
   Buffers: shared read=2
 Planning Time: 2.276 ms
 Execution Time: 21870.406 ms

(20 rows)

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:

postgres=# CREATE INDEX idx_articles_category_published
           ON articles (category, published)
;

postgres=# EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON)  
 SELECT id, published
  , published <=> '1970-01-01'::timestamp as " <=> 1970"
  , extract ( epoch from published )      as " epoch "
 FROM articles
 WHERE tsv @@ to_tsquery('english', 'postgresql')
   AND category = 'tech'
 ORDER BY published
 LIMIT 5
;

                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.036..0.050 rows=5 loops=1)
   Output: id, published, ((published <=> '1970-01-01 00:00:00'::timestamp without time zone)), (EXTRACT(epoch FROM published))
   Buffers: shared hit=9
   ->  Index Scan using idx_articles_category_published on public.articles (actual time=0.035..0.048 rows=5 loops=1)
         Output: id, published, (published <=> '1970-01-01 00:00:00'::timestamp without time zone), EXTRACT(epoch FROM published)
         Index Cond: (articles.category = 'tech'::text)
         Filter: (articles.tsv @@ '''postgresql'''::tsquery)
         Rows Removed by Filter: 20
         Buffers: shared hit=9
 Planning:
   Buffers: shared hit=2
 Planning Time: 0.129 ms
 Execution Time: 0.065 ms

(13 rows)

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:


SELECT documentdb_api.create_collection('db', 'articles');

SELECT documentdb_api.insert_one(
    'db',
    'articles',
    FORMAT(
        '{"_id": %s, "title": %s, "body": %s, "category": %s, "published": {"$date": {"$numberLong": "%s"}}, "score": %s, "words": %s}',
        to_json(id),
        to_json(title),
        to_json(body),
        to_json(category),
        (EXTRACT(EPOCH FROM published) * 1000)::bigint,
        to_json(score),
        to_json(tsvector_to_array(tsv))
    )::documentdb_core.bson
)
FROM articles;

SELECT documentdb_api_internal.create_indexes_non_concurrently('db',   
    '{ "createIndexes": "articles", "indexes": [ {   
        "key": { "words":1, "category": 1, "published": -1 },   
        "name": "idx_wrd_cat_pub"   
    } ] }',   
    true)
;

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.

Here is a similar query and its execution plan:


postgres=# SET documentdb_core.bsonUseEJson to true;

SET

postgres=# SELECT documentdb_api_catalog.bson_dollar_unwind(
 cursorpage, '$cursor.firstBatch'
) FROM documentdb_api.find_cursor_first_page(
    'db', '{
         "find": "articles",
         "filter": { "words": "postgresql", "category": "tech" },
         "sort": { "published": 1 },
         "limit": 5,
         "projection": { "_id": 1, "title": 1, "published": 1 }
}'::documentdb_core.bson
)
;
                                                                                                                                   bson_dollar_unwind

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
----------
 { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "db.articles", "firstBatch" : { "_id" : { "$numberInt" : "20" }, "title" : "Article about postgresql and postgresql", "published" : { "$date" : { "$numberLong" : "1577908800000" } } } }, "ok" : { "$numberDouble" : "
1.0" } }
 { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "db.articles", "firstBatch" : { "_id" : { "$numberInt" : "40" }, "title" : "Article about postgresql and postgresql", "published" : { "$date" : { "$numberLong" : "1577980800000" } } } }, "ok" : { "$numberDouble" : "
1.0" } }
 { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "db.articles", "firstBatch" : { "_id" : { "$numberInt" : "60" }, "title" : "Article about postgresql and postgresql", "published" : { "$date" : { "$numberLong" : "1578052800000" } } } }, "ok" : { "$numberDouble" : "
1.0" } }
 { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "db.articles", "firstBatch" : { "_id" : { "$numberInt" : "80" }, "title" : "Article about postgresql and postgresql", "published" : { "$date" : { "$numberLong" : "1578124800000" } } } }, "ok" : { "$numberDouble" : "
1.0" } }
 { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "db.articles", "firstBatch" : { "_id" : { "$numberInt" : "100" }, "title" : "Article about postgresql and postgresql", "published" : { "$date" : { "$numberLong" : "1578196800000" } } } }, "ok" : { "$numberDouble" :
"1.0" } }

(5 rows)

postgres=# EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON)
SELECT documentdb_api_catalog.bson_dollar_unwind(
 cursorpage, '$cursor.firstBatch'
) FROM documentdb_api.find_cursor_first_page(
    'db', '{
         "find": "articles",
         "filter": { "words": "postgresql", "category": "tech" },
         "sort": { "published": 1 },
         "limit": 5,
         "projection": { "_id": 1, "title": 1, "published": 1 }
}'::documentdb_core.bson
)
;
                                                                                                                                                                                                                                                                                                                                                                                                     QUERY PLAN

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------------------------
 ProjectSet (actual time=0.294..0.295 rows=5 loops=1)
   Output: bson_dollar_unwind(cursorpage, '$cursor.firstBatch'::text)
   Buffers: shared hit=14
   ->  Function Scan on documentdb_api.find_cursor_first_page (actual time=0.284..0.284 rows=1 loops=1)
         Output: cursorpage, continuation, persistconnection, cursorid
         Function Call: documentdb_api.find_cursor_first_page('db'::text, '{ "find" : "articles", "filter" : { "words" : "postgresql", "category" : "tech" }, "sort" : { "published" : { "$numberInt" : "1" } }, "limit" : { "$numberInt" : "5" }, "projection" : { "_id" : {
"$numberInt" : "1" }, "title" : { "$numberInt" : "1" }, "published" : { "$numberInt" : "1" } } }'::bson, '0'::bigint)
         Buffers: shared hit=14
 Planning Time: 0.046 ms
 Execution Time: 0.308 ms

(9 rows)

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:

postgres=# EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON)
SELECT document FROM documentdb_api_catalog.bson_aggregation_pipeline(
    'db',
    '{"aggregate": "articles", "pipeline": [
        {"$match": {  "words": "postgresql", "category": "tech" }},
        {"$sort": { "published": 1 }},
        {"$limit": 5},
        {"$project": { "_id": 1, "title": 1, "published": 1 }}
    ], "cursor": {}}'::documentdb_core.bson
);
                                                                                                                                                                                                                                                                                                                                                                          ... (truncated)
                                    

July 02, 2026

How CRED uses Amazon RDS Blue/Green Deployments at scale

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.

Why Percona Backup for MongoDB Is the Right Choice for Production Backups

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

The post Why Percona Backup for MongoDB Is the Right Choice for Production Backups appeared first on Percona.

Still on MySQL 5.7 or 8.0? Those high-severity CVE fixes are covered

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

The post Still on MySQL 5.7 or 8.0? Those high-severity CVE fixes are covered appeared first on Percona.

July 01, 2026

Village News: Village News: MySQL News + Events (1 July 2026)

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. 

Enjoy!

MySQL News:

Note: Aggregated MySQL

kernel asynchronous reads in PostgreSQL 19 (io_uring)

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:

docker run -d --name pg19 \
  -p 5432:5432 \
  -e POSTGRES_PASSWORD=xxx \
  --security-opt seccomp=unconfined \
  postgres:19beta1 \
  -c io_method=io_uring

I connected (PGUSER=postgres PGPASSWORD=xxx PGHOST=localhost psql) and checked the configuration:


postgres=# \dconfig io_*

  List of configuration parameters
         Parameter         |  Value
---------------------------+----------
 io_combine_limit          | 128kB
 io_max_combine_limit      | 128kB
 io_max_concurrency        | 64
 io_max_workers            | 8
 io_method                 | io_uring
 io_min_workers            | 2
 io_worker_idle_timeout    | 1min
 io_worker_launch_interval | 100ms

(8 rows)

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:

postgres=# explain (analyze, buffers, io, costs off)
select count(*),avg(length(data)) from smalldocs;

                                              QUERY PLAN
------------------------------------------------------------------------------------------------------
 Finalize Aggregate (actual time=941.539..943.440 rows=1.00 loops=1)
   Buffers: shared hit=15019 read=131281 dirtied=801 written=432
   ->  Gather (actual time=941.398..943.428 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=15019 read=131281 dirtied=801 written=432
         ->  Partial Aggregate (actual time=939.501..939.502 rows=1.00 loops=3)
               Buffers: shared hit=15019 read=131281 dirtied=801 written=432
               ->  Parallel Seq Scan on smalldocs (actual time=0.033..155.375 rows=341333.33 loops=3)
                     Prefetch: avg=74.32 max=91 capacity=94
                     I/O: count=8247 waits=54 size=15.92 in-progress=4.97
                     Buffers: shared hit=15019 read=131281 dirtied=801 written=432
                     Worker 0:  Prefetch: avg=74.41 max=91 capacity=94
                       I/O: count=2695 waits=30 size=15.93 in-progress=4.98
                     Worker 1:  Prefetch: avg=73.99 max=91 capacity=94
                       I/O: count=2760 waits=13 size=15.88 in-progress=4.95
 Planning:
   Buffers: shared hit=5
 Planning Time: 0.094 ms
 Execution Time: 943.470 ms
(20 rows)

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:


# echo 3 | sudo tee /proc/sys/vm/drop_caches &&
  strace -fyye trace=io_uring_enter,io_uring_setup,io_uring_enter,io_uring_register -s 0 -qq \
         -p $(pgrep -fd, "postgres: ") -T -o /dev/stdout

2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = -1 EINTR (Interrupted system call) <0.000307>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000019>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.001068>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000034>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000041>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000714>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000024>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000720>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000028>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000505>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000015>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000046>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000406>
2143284 io_uring_enter(4, 2, 0, 0, NULL, 8) = 2 <0.000043>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000088>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000209>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000096>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000020>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000018>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000026>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000024>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000017>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000031>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000053>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000022>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000013>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000020>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000560>
...

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:

postgres=# \! echo 3 | sudo tee /proc/sys/vm/drop_caches
3
postgres=# set max_parallel_workers_per_gather = 5;
SET
postgres=# explain (analyze, buffers, io, settings, costs off)
select count(*),avg(length(data)) from smalldocs;
                                               QUERY PLAN
--------------------------------------------------------------------------------------------------------
 Finalize Aggregate (actual time=48293.834..48295.976 rows=1.00 loops=1)
   Buffers: shared hit=9776 read=136512
   ->  Gather (actual time=48271.604..48295.958 rows=6.00 loops=1)
         Workers Planned: 5
         Workers Launched: 5
         Buffers: shared hit=9776 read=136512
         ->  Partial Aggregate (actual time=48237.365..48237.365 rows=1.00 loops=6)
               Buffers: shared hit=9776 read=136512
               ->  Parallel Seq Scan on smalldocs (actual time=1.863..47814.487 rows=170666.67 loops=6)
                     Prefetch: avg=80.29 max=92 capacity=94
                     I/O: count=8661 waits=8429 size=15.76 in-progress=4.97
                     Buffers: shared hit=9776 read=136512
                     Worker 0:  Prefetch: avg=82.96 max=91 capacity=94
                       I/O: count=1434 waits=1418 size=15.95 in-progress=4.93
                     Worker 1:  Prefetch: avg=80.43 max=91 capacity=94
                       I/O: count=1406 waits=1394 size=15.87 in-progress=4.89
                     Worker 2:  Prefetch: avg=77.46 max=92 capacity=94
                       I/O: count=1428 waits=1384 size=15.66 in-progress=4.91
                     Worker 3:  Prefetch: avg=81.88 max=91 capacity=94
                       I/O: count=1451 waits=1417 size=15.69 in-progress=5.02
                     Worker 4:  Prefetch: avg=76.47 max=91 capacity=94
                       I/O: count=1434 waits=1395 size=15.69 in-progress=5.03
 Settings: max_parallel_workers_per_gather = '5'
 Planning Time: 0.060 ms
 Execution Time: 48296.009 ms
(25 rows)

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 -o lowerdir=/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 -o lowerdir=/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:


load average: 25.11, 10.84, 4.17
%Cpu(s): 2.4 us, 1.5 sy, 95.3 wa

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.

Cross-account and cross-Region monitoring for Amazon RDS and Aurora with Database Insights

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.

June 30, 2026

Multi-block buffered reads in PostgreSQL 19 (IO combine & prefetch)

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          \
 -e POSTGRES_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:


postgres=# \dconfig io_*

  List of configuration parameters
         Parameter         | Value
---------------------------+--------
 io_combine_limit          | 128kB
 io_max_combine_limit      | 128kB
 io_max_concurrency        | 64
 io_max_workers            | 8
 io_method                 | worker
 io_min_workers            | 2
 io_worker_idle_timeout    | 1min
 io_worker_launch_interval | 100ms

(8 rows)

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:

postgres=# create table "smalldocs" ( "id" bigserial, "data" text );

CREATE TABLE

postgres=# copy "smalldocs" ("data") from program $sh$
            base64 -w $((1024)) /dev/urandom | head -102400
            $sh$
           \watch c=10 i=0.01

COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400
COPY 102400

postgres=# select count(*),avg(length(data)) from smalldocs;

  count  |          avg
---------+-----------------------
 1024000 | 1024.0000000000000000

(1 row)

postgres=# select pg_size_pretty(pg_total_relation_size('smalldocs'));

 pg_size_pretty
----------------
 1143 MB

(1 row)

I have a 1GB table. Reading it with a Seq Scan benefits from IO combine, and the IO format of EXPLAIN ANALYZE displays the prefetch statistics:


postgres=# explain (analyze, buffers, io)
select count(*),avg(length(data)) from smalldocs;
                                                                     QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=154754.69..154754.70 rows=1 width=40) (actual time=898.198..903.139 rows=1.00 loops=1)
   Buffers: shared hit=15937 read=130351
   ->  Gather  (cost=154754.46..154754.67 rows=2 width=40) (actual time=898.083..903.129 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=15937 read=130351
         ->  Partial Aggregate  (cost=153754.46..153754.47 rows=1 width=40) (actual time=895.650..895.650 rows=1.00 loops=3)
               Buffers: shared hit=15937 read=130351
               ->  Parallel Seq Scan on smalldocs  (cost=0.00..150554.55 rows=426655 width=1028) (actual time=0.113..98.247 rows=341333.33 loops=3)
                     Prefetch: avg=34.59 max=91 capacity=94
                     I/O: count=8207 waits=19 size=15.88 in-progress=2.86
                     Buffers: shared hit=15937 read=130351
                     Worker 0:  Prefetch: avg=33.72 max=64 capacity=94
                       I/O: count=2861 waits=6 size=15.87 in-progress=2.81
                     Worker 1:  Prefetch: avg=34.45 max=64 capacity=94
                       I/O: count=2571 waits=6 size=15.91 in-progress=2.87
 Planning Time: 0.064 ms
 Execution Time: 903.174 ms

(18 rows)

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:

# strace -fyye trace=pread64,pwrite64 \
  -p $(pgrep -fd, "postgres: io worker")  -s 0 -qq -o /dev/stdout

4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 8192, 0) = 8192
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 253952) = 131072
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 385024) = 131072
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 516096) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004804 <... pread64 resumed>""..., 131072, 1040384) = 131072
4004803 <... pread64 resumed>""..., 8192, 2097152) = 8192
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004804 <... pread64 resumed>""..., 131072, 1171456) = 131072
4004803 <... pread64 resumed>""..., 8192, 3145728) = 8192
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 16384, 2105344) = 16384
4004803 <... pread64 resumed>""..., 131072, 1302528) = 131072
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 16384, 3153920) = 16384
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 32768, 2121728) = 32768
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 32768, 3170304) = 32768
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004804 <... pread64 resumed>""..., 131072, 1826816) = 131072
4004803 <... pread64 resumed>""..., 131072, 3268608) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>,  <unfinished ...>
4004804 <... pread64 resumed>""..., 131072, 2220032) = 131072
4004803 <... pread64 resumed>""..., 131072, 1957888) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 8192, 2088960) = 8192
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 4194304) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 2482176) = 131072
4004803 pread64(30</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 3530752) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 2613248) = 131072
4004804 pread64(8</var/lib/postgresql/19/docker/base/5/16389>, ""..., 131072, 3661824) = 131072
...

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:

postgres=# create table "largedocs" ( "id" bigserial, "data" text );

CREATE TABLE

postgres=# copy "largedocs" ("data") from program $sh$
            base64 -w $((1024*1024)) /dev/urandom | head -100
            $sh$
           \watch c=10 i=0.01

COPY 100
COPY 100
COPY 100
COPY 100
COPY 100
COPY 100
COPY 100
COPY 100
COPY 100
COPY 100

postgres=# select count(*),avg(length(data)) from largedocs;

 count |         avg
-------+----------------------
  1000 | 1048576.000000000000

(1 row)

postgres=# select pg_size_pretty(pg_total_relation_size('largedocs'));

 pg_size_pretty
----------------
 1039 MB

(1 row)

I run the same query as before on this new table with fewer rows but TOASTed data:

postgres=# explain (analyze, buffers, io)
select count(*),avg(length(data)) from largedocs;

                                                     QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=25.50..25.51 rows=1 width=40) (actual time=2775.170..2775.171 rows=1.00 loops=1)
   Buffers: shared hit=3432 read=132951
   ->  Seq Scan on largedocs  (cost=0.00..18.00 rows=1000 width=18) (actual time=0.365..1.049 rows=1000.00 loops=1)
         Prefetch: avg=1.88 max=4 capacity=94
         I/O: count=4 waits=1 size=2.00 in-progress=1.00
         Buffers: shared read=8
 Planning Time: 0.055 ms
 Execution Time: 2775.194 ms

(8 rows)

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):

4021362 pread64(14</var/lib/postgresql/19/docker/base/5/16397>, ""..., 8192, 0) = 8192
4021362 pread64(14</var/lib/postgresql/19/docker/base/5/16397>, ""..., 16384, 8192) = 16384
4021362 pread64(14</var/lib/postgresql/19/docker/base/5/16397>, ""..., 32768, 24576) = 32768
4021362 pread64(14</var/lib/postgresql/19/docker/base/5/16397>, ""..., 8192, 57344) = 8192

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":

postgres=# select c.oid, relkind, amname, relname 
            from pg_class c join pg_am a on c.relam = a.oid 
            where c.oid>='smalldocs'::regclass order by c.oid
;

  oid  | relkind | amname |       relname
-------+---------+--------+----------------------
 16389 | r       | heap   | smalldocs
 16394 | t       | heap   | pg_toast_16389
 16395 | i       | btree  | pg_toast_16389_index
 16397 | r       | heap   | largedocs
 16402 | t       | heap   | pg_toast_16397
 16403 | i       | btree  | pg_toast_16397_index

(6 rows)

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:

# strace -fyye trace=pread64,pwrite64 -s 0 -qq  \
  -p $(pgrep -fd, "postgres: ") -o /dev/stdout

4004803 pread64(33</var/lib/postgresql/19/docker/base/5/16397>, ""..., 8192, 0) = 8192
4004969 pread64(84</var/lib/postgresql/19/docker/base/5/16403>,  <unfinished ...>
4004803 pread64(33</var/lib/postgresql/19/docker/base/5/16397>,  <unfinished ...>
4004969 <... pread64 resumed>""..., 8192, 24576) = 8192
4004803 <... pread64 resumed>""..., 16384, 8192) = 16384
4004969 pread64(84</var/lib/postgresql/19/docker/base/5/16403>, ""..., 8192, 8192) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 0) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 8192) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 16384) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 24576) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 32768) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 40960) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 49152) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 57344) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 65536) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 73728) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 81920) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 90112) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 98304) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 106496) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 114688) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 122880) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 131072) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 139264) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 147456) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 155648) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 163840) = 8192
4004969 pread64(83</var/lib/postgresql/19/docker/base/5/16402>, ""..., 8192, 172032) = 8192

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:

echo 3 | sudo tee /proc/sys/vm/drop_caches
sudo bpftrace -e '
tracepoint:block:block_rq_issue
{
  @bytes[args->bytes] = count();
}
interval:s:30
{
  exit();
}' |  sort -t: -k2 -rn | paste - - - - - -

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:

@bytes[1048576]: 782    @bytes[16384]: 672      @bytes[4096]: 547       @bytes[131072]: 277     @bytes[8192]: 170       @bytes[516096]: 130
@bytes[24576]: 93       @bytes[393216]: 78      @bytes[32768]: 77       @bytes[262144]: 52      @bytes[40960]: 46       @bytes[65536]: 40
@bytes[49152]: 40       @bytes[155648]: 34      @bytes[81920]: 32       @bytes[106496]: 32      @bytes[98304]: 31       @bytes[122880]: 31
@bytes[90112]: 30       @bytes[73728]: 30       @bytes[57344]: 28       @bytes[163840]: 27      @bytes[1044480]: 27     @bytes[237568]: 24
@bytes[139264]: 24      @bytes[114688]: 23      @bytes[196608]: 22      @bytes[188416]: 22      @bytes[147456]: 22      @bytes[180224]: 21
@bytes[172032]: 21      @bytes[229376]: 20      @bytes[524288]: 19      @bytes[253952]: 17      @bytes[212992]: 16      @bytes[204800]: 15
@bytes[12288]: 14       @bytes[221184]: 13      @bytes[303104]: 12      @bytes[245760]: 11      @bytes[69632]: 10       @bytes[376832]: 10
@bytes[360448]: 10      @bytes[270336]: 10      @bytes[786432]: 9       @bytes[655360]: 7       @bytes[294912]: 7       @bytes[278528]: 7
@bytes[638976]: 6       @bytes[401408]: 6       @bytes[20480]: 6        @bytes[917504]: 5       @bytes[53248]: 5        @bytes[425984]: 5
@bytes[327680]: 5       @bytes[319488]: 5       @bytes[258048]: 5       @bytes[1040384]: 5      @bytes[77824]: 4        @bytes[720896]: 4
@bytes[532480]: 4       @bytes[499712]: 4       @bytes[36864]: 4        @bytes[344064]: 4       @bytes[311296]: 4       @bytes[286720]: 4
@bytes[217088]: 4       @bytes[135168]: 4       @bytes[1032192]: 4      @bytes[991232]: 3       @bytes[983040]: 3       @bytes[925696]: 3
@bytes[86016]: 3        @bytes[851968]: 3       @bytes[802816]: 3       @bytes[770048]: 3       @bytes[745472]: 3       @bytes[61440]: 3
@bytes[573440]: 3       @bytes[540672]: 3       @bytes[512000]: 3       @bytes[507904]: 3       @bytes[491520]: 3       @bytes[458752]: 3
@bytes[450560]: 3       @bytes[434176]: 3       @bytes[405504]: 3       @bytes[385024]: 3       @bytes[380928]: 3       @bytes[368640]: 3
@bytes[352256]: 3       @bytes[335872]: 3       @bytes[274432]: 3       @bytes[266240]: 3       @bytes[249856]: 3       @bytes[208896]: 3
@bytes[184320]: 3       @bytes[159744]: 3       @bytes[999424]: 2       @bytes[966656]: 2       @bytes[884736]: 2       @bytes[876544]: 2
@bytes[819200]: 2       @bytes[782336]: 2       @bytes[761856]: 2       @bytes[757760]: 2       @bytes[753664]: 2       @bytes[729088]: 2
@bytes[724992]: 2       @bytes[647168]: 2       @bytes[643072]: 2       @bytes[626688]: 2       @bytes[585728]: 2       @bytes[557056]: 2
@bytes[45056]: 2        @bytes[442368]: 2       @bytes[421888]: 2       @bytes[339968]: 2       @bytes[323584]: 2       @bytes[307200]: 2
@bytes[282624]: 2       @bytes[167936]: 2       @bytes[118784]: 2       @bytes[110592]: 2       @bytes[1036288]: 2      @bytes[1028096]: 2
@bytes[1024000]: 2      @bytes[1007616]: 2      @bytes[970752]: 1       @bytes[958464]: 1       @bytes[950272]: 1       @bytes[94208]: 1
@bytes[942080]: 1       @bytes[933888]: 1       @bytes[909312]: 1       @bytes[905216]: 1       @bytes[901120]: 1       @bytes[892928]: 1
@bytes[860160]: 1       @bytes[843776]: 1       @bytes[839680]: 1       @bytes[835584]: 1       @bytes[827392]: 1       @bytes[811008]: 1
@bytes[806912]: 1       @bytes[798720]: 1       @bytes[794624]: 1       @bytes[765952]: 1       @bytes[749568]: 1       @bytes[733184]: 1
@bytes[712704]: 1       @bytes[692224]: 1       @bytes[671744]: 1       @bytes[659456]: 1       @bytes[630784]: 1       @bytes[622592]: 1
@bytes[614400]: 1       @bytes[602112]: 1       @bytes[593920]: 1       @bytes[589824]: 1       @bytes[565248]: 1       @bytes[552960]: 1
@bytes[548864]: 1       @bytes[544768]: 1       @bytes[528384]: 1       @bytes[503808]: 1       @bytes[487424]: 1       @bytes[483328]: 1
@bytes[475136]: 1       @bytes[466944]: 1       @bytes[462848]: 1       @bytes[454656]: 1       @bytes[446464]: 1       @bytes[430080]: 1
@bytes[417792]: 1       @bytes[413696]: 1       @bytes[409600]: 1       @bytes[397312]: 1       @bytes[389120]: 1       @bytes[372736]: 1
@bytes[356352]: 1       @bytes[331776]: 1       @bytes[290816]: 1       @bytes[28672]: 1        @bytes[200704]: 1       @bytes[143360]: 1
@bytes[102400]: 1       @bytes[1011712]: 1      Attaching 2 probes...

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:

@bytes[1048576]: 893    @bytes[16384]: 586      @bytes[4096]: 519       @bytes[8192]: 291       @bytes[516096]: 122     @bytes[24576]: 63
@bytes[65536]: 13       @bytes[57344]: 13       @bytes[32768]: 10       @bytes[163840]: 10      @bytes[131072]: 9       @bytes[122880]: 9
@bytes[73728]: 8        @bytes[40960]: 8        @bytes[327680]: 7       @bytes[262144]: 7       @bytes[303104]: 6       @bytes[221184]: 6
@bytes[212992]: 6       @bytes[172032]: 6       @bytes[1044480]: 6      @bytes[1007616]: 6      @bytes[90112]: 5        @bytes[671744]: 5
@bytes[61440]: 5        @bytes[532480]: 5       @bytes[524288]: 5       @bytes[49152]: 5        @bytes[237568]: 5       @bytes[188416]: 5
@bytes[155648]: 5       @bytes[114688]: 5       @bytes[98304]: 4        @bytes[901120]: 4       @bytes[868352]: 4       @bytes[786432]: 4
@bytes[69632]: 4        @bytes[647168]: 4       @bytes[557056]: 4       @bytes[475136]: 4       @bytes[450560]: 4       @bytes[401408]: 4
@bytes[368640]: 4       @bytes[352256]: 4       @bytes[286720]: 4       @bytes[270336]: 4       @bytes[245760]: 4       @bytes[20480]: 4
@bytes[12288]: 4        @bytes[1040384]: 4      @bytes[1015808]: 4      @bytes[991232]: 3       @bytes[876544]: 3       @bytes[86016]: 3
@bytes[835584]: 3       @bytes[761856]: 3       @bytes[737280]: 3       @bytes[720896]: 3       @bytes[696320]: 3       @bytes[688128]: 3
@bytes[679936]: 3       @bytes[614400]: 3       @bytes[581632]: 3       @bytes[53248]: 3        @bytes[466944]: 3       @bytes[442368]: 3
@bytes[360448]: 3       @bytes[335872]: 3       @bytes[294912]: 3       @bytes[278528]: 3       @bytes[229376]: 3       @bytes[180224]: 3
@bytes[147456]: 3       @bytes[135168]: 3       @bytes[118784]: 3       @bytes[106496]: 3       @bytes[1032192]: 3      @bytes[999424]: 2
@bytes[966656]: 2       @bytes[958464]: 2       @bytes[925696]: 2       @bytes[917504]: 2       @bytes[892928]: 2       @bytes[851968]: 2
@bytes[811008]: 2       @bytes[77824]: 2        @bytes[753664]: 2       @bytes[712704]: 2       @bytes[622592]: 2       @bytes[606208]: 2
@bytes[589824]: 2       @bytes[573440]: 2       @bytes[491520]: 2       @bytes[483328]: 2       @bytes[458752]: 2       @bytes[434176]: 2
@bytes[425984]: 2       @bytes[417792]: 2       @bytes[405504]: 2       @bytes[393216]: 2       @bytes[385024]: 2       @bytes[376832]: 2
@bytes[344064]: 2       @bytes[311296]: 2       @bytes[258048]: 2       @bytes[253952]: 2       @bytes[196608]: 2       @bytes[167936]: 2
@bytes[139264]: 2       @bytes[987136]: 1       @bytes[974848]: 1       @bytes[950272]: 1       @bytes[94208]: 1        @bytes[942080]: 1
@bytes[843776]: 1       @bytes[827392]: 1       @bytes[81920]: 1        @bytes[778240]: 1       @bytes[770048]: 1       @bytes[733184]: 1
@bytes[729088]: 1       @bytes[724992]: 1       @bytes[659456]: 1       @bytes[655360]: 1       @bytes[602112]: 1       @bytes[598016]: 1
@bytes[528384]: 1       @bytes[520192]: 1       @bytes[512000]: 1       @bytes[507904]: 1       @bytes[45056]: 1        @bytes[413696]: 1
@bytes[409600]: 1       @bytes[397312]: 1       @bytes[36864]: 1        @bytes[364544]: 1       @bytes[331776]: 1       @bytes[319488]: 1
@bytes[28672]: 1        @bytes[282624]: 1       @bytes[266240]: 1       @bytes[249856]: 1       @bytes[204800]: 1       @bytes[143360]: 1
@bytes[1024000]: 1      Attaching 2 probes...

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.

User authentication and session management with Amazon Aurora DSQL

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.

Community Docker Images: keeping the operator open without a vendor registry lock-in

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

The post Community Docker Images: keeping the operator open without a vendor registry lock-in appeared first on Percona.

Debugging with Ephemeral Containers

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

The post Debugging with Ephemeral Containers appeared first on Percona.

Why I haven’t run my databases on Kubernetes

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

The post Why I haven’t run my databases on Kubernetes appeared first on Percona.

Lightweight deletes for Tinybird Data Sources now in Beta

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.

June 29, 2026

Hybrid Search (Full-Text and Vector Similarity) in HorizonDB

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:

Here is my HorizonDB configuration:

postgres=> select version();
                                                        version
-----------------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.9 (Azure HorizonDB (70f3b593ec7)(release)) on x86_64-pc-linux-gnu, compiled by gcc (GCC) 13.2.0, 64-bit

postgres=> show azure.extensions;

             azure.extensions
------------------------------------------
 pg_diskann,vector,pg_textsearch,azure_ai

postgres=> show shared_preload_libraries;

                               shared_preload_libraries
---------------------------------------------------------------------------------------
 pg_textsearch,azure,orion_storage,pg_availability,pg_qs,pgms_stats,pgms_wait_sampling

I've set up the extension features, functions, data types, and operators using CREATE EXTENSIONS and created a schema for this demo:


create extension if not exists vector;
create extension if not exists pg_textsearch;
create extension if not exists pg_diskann;

drop schema if exists hybrid_demo cascade;
create schema hybrid_demo;

set search_path = hybrid_demo, public, pgfts;

For the demo: a deterministic embedding function

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.

create or replace function demo_embedding(txt text)
returns vector(16)
language sql
immutable
parallel safe
as $$
select (
  '[' ||
  concat_ws(',',
    case when txt ~* '(mid|century)'       then '1.0' else '0.0' end,
    case when txt ~* '(modern|minimalist)' then '1.0' else '0.0' end,
    case when txt ~* '(chair|seat)'        then '1.0' else '0.0' end,
    case when txt ~* '(table|desk)'        then '1.0' else '0.0' end,
    case when txt ~* '(sofa|couch)'        then '1.0' else '0.0' end,
    case when txt ~* '(lamp|light)'        then '1.0' else '0.0' end,
    case when txt ~* '(wood|walnut|oak)'   then '1.0' else '0.0' end,
    case when txt ~* '(metal|steel)'       then '1.0' else '0.0' end,
    case when txt ~* '(leather)'           then '1.0' else '0.0' end,
    case when txt ~* '(fabric|linen)'      then '1.0' else '0.0' end,
    case when txt ~* '(industrial)'        then '1.0' else '0.0' end,
    case when txt ~* '(scandinavian)'      then '1.0' else '0.0' end,
    case when txt ~* '(office)'            then '1.0' else '0.0' end,
    case when txt ~* '(dining)'            then '1.0' else '0.0' end,
    case when txt ~* '(classic|vintage)'   then '1.0' else '0.0' end,
    case when txt ~* '(outdoor|garden)'    then '1.0' else '0.0' end
  ) ||
  ']'
)::vector;
$$;

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:

create table products (
  product_id bigint primary key,
  category   text not null,
  brand      text not null,
  price      numeric(10,2) not null,
  title      text not null,
  description text not null,
  search_text text generated always as (
    title || ' ' || description
  ) stored,
  embedding vector(16) not null
);

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:

insert into products ( product_id, category, brand, price, title, description, embedding)
with generated as (
  select
    g as product_id,
    (array[ 'chair', 'table', 'sofa', 'lamp', 'desk', 'shelf' ])[1 + (g % 6)] as category,
    (array[ 'Contoso', 'Fabrikam', 'Northwind', 'AdventureWorks', 'Wingtip', 'Tailspin' ])[1 + (g % 6)] as brand,
    (array[ 'mid-century modern', 'industrial', 'scandinavian', 'classic', 'minimalist', 'outdoor' ])[1 + (g % 6)] as style,
    (array[ 'walnut wood', 'black metal', 'oak wood', 'leather', 'fabric', 'brushed steel' ])[1 + ((g / 7) % 6)] as material, (25 + (g % 500))::numeric(10,2) as price
  from generate_series(1, 200000) as g
)
select product_id, category, brand, price,
  initcap(style || ' ' || material || ' ' || category) as title,
  concat( style, ' ', category, ' by ', brand, ' with ', material, '. Designed for ',
    case
      when category in ('chair', 'sofa') then 'living room seating'
      when category in ('table', 'desk') then 'home office and dining'
      when category = 'lamp' then 'warm interior lighting'
      else 'storage and display'
    end,
    '. Product code ', product_id, '.'
  ) as description,
  demo_embedding(
    concat_ws(' ', style, material, category, brand)
  ) as embedding
from generated;

I checked a few rows:

postgres=>
 select product_id, title, category, brand, price, search_text, embedding
from products where price >= 42
order by product_id
limit 10;

 product_id |               title               | category |     brand      | price |                                                               search_text                                                               |             embedding
------------+-----------------------------------+----------+----------------+-------+-----------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------
         17 | Outdoor Oak Wood Shelf            | shelf    | Tailspin       | 42.00 | Outdoor Oak Wood Shelf outdoor shelf by Tailspin with oak wood. Designed for storage and display. Product code 17.                      | [0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1]
         18 | Mid-Century Modern Oak Wood Chair | chair    | Contoso        | 43.00 | Mid-Century Modern Oak Wood Chair mid-century modern chair by Contoso with oak wood. Designed for living room seating. Product code 18. | [1,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0]
         19 | Industrial Oak Wood Table         | table    | Fabrikam       | 44.00 | Industrial Oak Wood Table industrial table by Fabrikam with oak wood. Designed for home office and dining. Product code 19.             | [0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0]
         20 | Scandinavian Oak Wood Sofa        | sofa     | Northwind      | 45.00 | Scandinavian Oak Wood Sofa scandinavian sofa by Northwind with oak wood. Designed for living room seating. Product code 20.             | [0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0]
         21 | Classic Leather Lamp              | lamp     | AdventureWorks | 46.00 | Classic Leather Lamp classic lamp by AdventureWorks with leather. Designed for warm interior lighting. Product code 21.                 | [0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0]
         22 | Minimalist Leather Desk           | desk     | Wingtip        | 47.00 | Minimalist Leather Desk minimalist desk by Wingtip with leather. Designed for home office and dining. Product code 22.                  | [0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0]
         23 | Outdoor Leather Shelf             | shelf    | Tailspin       | 48.00 | Outdoor Leather Shelf outdoor shelf by Tailspin with leather. Designed for storage and display. Product code 23.                        | [0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1]
         24 | Mid-Century Modern Leather Chair  | chair    | Contoso        | 49.00 | Mid-Century Modern Leather Chair mid-century modern chair by Contoso with leather. Designed for living room seating. Product code 24.   | [1,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0]
         25 | Industrial Leather Table          | table    | Fabrikam       | 50.00 | Industrial Leather Table industrial table by Fabrikam with leather. Designed for home office and dining. Product code 25.               | [0,0,0,1,0,0,0,0,1,0,1,0,0,0,0,0]
         26 | Scandinavian Leather Sofa         | sofa     | Northwind      | 51.00 | Scandinavian Leather Sofa scandinavian sofa by Northwind with leather. Designed for living room seating. Product code 26.               | [0,0,0,0,1,0,0,0,1,0,0,1,0,0,0,0]


(10 rows)

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:


postgres=> create index products_bm25_idx
            on products
            using bm25 (search_text)
            with (text_config = 'english')
;

NOTICE:  BM25 index build started for relation products_bm25_idx
NOTICE:  Using text search configuration: english
NOTICE:  Using index options: k1=1.20, b=0.75
NOTICE:  parallel index build: launched 2 of 2 requested workers

NOTICE:  BM25 index build completed: 200000 documents, avg_length=16.17

CREATE INDEX

Then I created the vector index using DiskANN and cosine similarity:


postgres=> create index products_embedding_diskann_idx
            on products
            using diskann (embedding vector_cosine_ops)
;

CREATE INDEX

I gathered the statistics:


postgres=> vacuum analyze products;

ANALYZE

My data set is ready for queries.

Query 1: BM25 only

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=> prepare query1 (text, int) as
select
  p.product_id,
  p.title,
  p.category,
  p.brand,
  p.price
from products p
order by p.search_text <@> to_bm25query( $1 , 'products_bm25_idx' )
limit $2;

postgres=> explain (analyze, buffers, verbose, costs off) 
           execute query1 ('mid century modern wooden chair', 10)
;

                                                                 QUERY PLAN                                                          
---------------------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.268..0.442 rows=10 loops=1)
   Output: product_id, title, category, brand, price, ((search_text <@> 'products_bm25_idx:mid century modern wooden chair'::bm25query))
   Buffers: shared hit=582
   ->  Index Scan using products_bm25_idx on hybrid_demo.products p (actual time=0.267..0.440 rows=10 loops=1)
         Output: product_id, title, category, brand, price, (search_text <@> 'products_bm25_idx:mid century modern wooden chair'::bm25query)
         Order By: (p.search_text <@> 'products_bm25_idx:mid century modern wooden chair'::bm25query)
         Buffers: shared hit=582
 Query Identifier: -4837396746836655100
 Planning:
   Buffers: shared hit=1
 Planning Time: 0.116 ms
 Execution Time: 0.460 ms

(12 rows)

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:

postgres=> prepare query2 (text, int) as
select
  p.product_id,
  p.title,
  p.category,
  p.brand,
  p.price,
  p.embedding <=> demo_embedding($1) as distance
from products p
order by p.embedding <=> demo_embedding($1)
limit $2;

postgres=> explain (analyze, buffers, verbose, costs off)
execute query2 ('modern wooden chair', 10);

                                                         QUERY PLAN                                                     
----------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.374..0.399 rows=10 loops=1)
   Output: product_id, title, category, brand, price, ((embedding <=> '[0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0]'::vector))
   Buffers: shared hit
                                    
                                    
                                    
                                    
                                

Skipping Percona Server for MySQL 8.4.9 and 9.7.0

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 post Skipping Percona Server for MySQL 8.4.9 and 9.7.0 appeared first on Percona.

CedarDB Launches HTAP (10 Years Ago)

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.

Morsel-Driven Parallelism, a research paper from the research project behind CedarDB

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.

June 27, 2026

Azure AI on HorizonDB

The azure_ai extension on HorizonDB adds generative AI functions to PostgreSQL, allowing users to utilize Azure AI's generation, ranking, and embedding models. Here's a four-step example that demonstrates how to define default models, set up endpoints, register them, and use the in SQL queries.

1. Allow and install the extension

The azure_ai extension must be set in azure.extensions from the parameter group:

Once enabled, you can CREATE EXTENSION:

postgres=> SHOW azure.extensions;

             azure.extensions
------------------------------------------
 pg_diskann,vector,pg_textsearch,azure_ai

(1 row)

postgres=> CREATE EXTENSION IF NOT EXISTS azure_ai;

CREATE EXTENSION

The functions are available, but I don't have access to an AI model yet:

postgres=> SELECT azure_ai.generate(
            'Hello'
           );

ERROR:  Endpoint not found.
DETAIL:  Please set/register the model.

An AI Model Management feature is coming to HorizonDB, currently in private preview, which is basically a zero-setup mode for azure_ai, but for the moment, I will do it manually.

2. Deploy a model in Azure

I go to the Microsoft Foundry | Azure OpenAI, hit "Create" and select "Azure OpenAI":

I set my resource group and region:

I use the default network setting that allows all networks, including the internet, to access this resource.

Once created, the next step is to "go to resource" in order to deploy a model:

In the model catalog, I select the chat models, for LLM tasks and generation purposes:

I hit "Uset this model" and deploy gpt-4o-mini:

The URL and API key is displayed in the Home section:

The details for the available models is in the Deployments section:

I can also get the parameters from the Python sample:

endpoint = "https://frankpachot-ai.openai.azure.com/"
model_name = "gpt-4o-mini"
deployment = "gpt-4o-mini"

subscription_key = "<your-api-key>"
api_version = "2024-12-01-preview"

client = AzureOpenAI(
    api_version=api_version,
    azure_endpoint=endpoint,
    api_key=subscription_key,

This information will be used to register the model from PostgreSQL.

I'll use them directly, but in production, keys should be stored in a secrets management system instead of being hardcoded in SQL.

3. Register the model in PostgreSQL

In HorizonDB, I can add AI models with model_registry.model_add() which takes the following parameters:

parameter meaning
alias SQL name
endpoint where to call the model
deployment which model instance
model_name metadata / capability
api_version protocol version
auth_type how to authenticate
key credential

The deployment name must exactly match the one defined in Azure. This is not the model name but the deployment identifier.

Here is the registration with the information from the chat model I deployed:

postgres=> SELECT model_registry.model_add(
    'default-chat',                                 -- alias
    'https://frankpachot-ai.openai.azure.com/',     -- azure_endpoint
    'gpt-4o-mini',                                  -- deployment name
    'gpt-4o-mini',                                      -- model name
    '2024-12-01-preview',                           -- api_version
    'subscription-key',                             -- auth type
    'FR3Xcz5VXiHSbz8Eqeo5qXsyKqgxrFeYCSuqOv...'     -- api_key
);

                       model_add
--------------------------------------------------------
 Model 'default-chat' (gpt-4o-mini) added successfully.

(1 row)


PostgreSQL can now invoke the Azure OpenAI deployment.

With this solution, the database itself doesn’t host the model. It only contains the information needed to call it, such as the endpoint, deployment, and key, but the calls to Azure OpenAI are transparent to the users.

4. Use the model from SQL queries

Now, azure_ai.generate() can use the registered model for generative AI:

postgres=> SELECT azure_ai.generate(
    'Who is Slonik and how does he look like? context:'|| version()
  , 'default-chat'
);

generate     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Slonik is a mascot for PostgreSQL, often depicted as a friendly, cartoonish elephant. He typically has a blue-gray body, large floppy ears, and a cheerful expression. Slonik embodies the PostgreSQL community's spirit and is often used in promotional materials and events related to PostgreSQL.
(1 row)

postgres=>

The model name default-chat is the default for this function. I can omit it.

Instead of employing the model for text generation, I can utilize it to validate my text with azure_ai.is_true().

Let's verify the correct names for our favorite database:

postgres=> select azure_ai.is_true( format (
           '%s is the right name for Slonik''s database', unnest
            )), string_agg(unnest,',') from unnest(ARRAY[
            'PostgreSQL','Postgres','PG','pgsql','postgresql',
            'POSTGRES','pgdb','postgres-db','SQL','psql',
            'postmaster','postgré','postgrès','posgress',
            'posgresql','postgrasql','postgray','postgrest',
            'postgrezql','postgrex','postgresesql','postgresequel',
            'Post-Ingres',' Post-Gres-Q-L','Postgres95','pg-sql',
            'pgserver','pg-database','HorizonDB',
            'slonik-db','elephant-db','the elephant'
             ]) group by 1 order by 1 desc;

agg
---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 t       | PostgreSQL,Postgres,postgresql,POSTGRES,the elephant
 f       | PG,pgsql,pgdb,postgres-db,SQL,psql,postmaster,postgré,postgrès,posgress,posgresql,postgrasql,postgray,postgrest,postgrezql,postgrex,postgresesql,postgresequel,Post-Ingres, Post-Gres-Q-L,Postgres95,pg-sql,pgserver,pg-database,HorizonDB,slonik-db,elephant-db

(2 rows)

Ok, according to gpt-4o-mini, "The Elephant" is a valid name for PostgreSQL.

The chat model can also be used to extract structured information from unstructured text. For example, from the version() banner I'm interested in the PostgreSQL compatibility version, and the name of the managed service:

postgres=> SELECT version();

                                                        version
-----------------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.9 (Azure HorizonDB (70f3b593ec7)(release)) on x86_64-pc-linux-gnu, compiled by gcc (GCC) 13.2.0, 64-bit
(1 row)

postgres=> SELECT azure_ai.extract(
             version() , -- text from which to extract
             ARRAY['PostgreSQL compatibility','Cloud service name']
            );

                                    extract
-------------------------------------------------------------------------------
 {"Cloud service name": "Azure HorizonDB", "PostgreSQL compatibility": "17.9"}

(1 row)

In order to generate embeddings, I need to deploy a dedicated model:

With the same method I used for chat models, I got the endpoint, name, and key to access this embedding model:

postgres=> SELECT model_registry.model_add(
    'default-embedding',                            -- alias
    'https://franckpachot-ai.openai.azure.com/',     -- azure_endpoint
    'text-embedding-3-small',                       -- deployment name
    'text-embedding-3-small',                       -- model name
    '2024-12-01-preview',                           -- api_version
    'subscription-key',                             -- auth type
    'FR3Xcz5VXiHSbz8Eqeo5qXsyKqgxrFeYCSuqOv...'     -- api_key
);

                              model_add
---------------------------------------------------------------------
 Model 'default-embedding' (text-embedding-3-small) added successfully.

(1 row)

Using the alias default-embedding, which I defined when registering the model deployment, I can generate embeddings for specific text:

postgres=> SELECT azure_openai.create_embeddings(
    'default-embedding',
    'hello world'
);
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 create_embeddings                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              
----------------------------------------------------------------
{-0.006729126,-0.03918457,0.03414917,0.028747559,-0.024841309,-0.041992188,-0.030288696,0.049316406,-0.013969421,-0.017669678,0.015396118,-0.026992798,-0.020980835,-0.027801514,0.008583069,0.03567505,-0.053619385,-0.0023059845,0.008773804,0.048034668,0.037078857,-0.009239197,-0.008781433,0.011428833,0.0140686035,-0.002161026,-0.037597656,0.04547119,0.0112838745,-0.03967285,0.0234375,-0.050628662,0.011985779,...}
(1 row)

postgres=>

Note that the azure_ai extension deploys functions in different namespaces. High‑level semantic operations, independent of the model vendor, such as generate(), is_true(), extract(), rank() are in the azure_ai schema. In contrast, create_embeddings, which returns vectors and is tied to the OpenAI API, is in the azure_openai schema.

I have also enabled pg_vector to use the vector data type and operators for generated embeddings. Here is an example using a prompt to find PostgreSQL settings related to shared buffer cache memory:

postgres=> CREATE EXTENSION vector;

CREATE EXTENSION

postgres=> WITH settings AS (
  SELECT name, short_desc, azure_openai.create_embeddings('default-embedding',
      row_to_json(pg_settings)::text
  )::vector AS embedding FROM pg_settings
) SELECT
  name, short_desc FROM settings
  ORDER BY embedding <=> azure_openai.create_embeddings('default-embedding',
    'Buffer cache shared memory allocated by PostgreSQL'
  )::vector LIMIT 5;

            name            |                                       short_desc
----------------------------+----------------------------------------------------------------------------------------
 effective_cache_size       | Sets the planner's assumption about the total size of the data caches.
 shared_memory_type         | Selects the shared memory implementation used for the main shared memory region.
 shared_buffers             | Sets the number of shared memory buffers used by the server.
 shared_memory_size         | Shows the size of the server's main shared memory area (rounded up to the nearest MB).
 dynamic_shared_memory_type | Selects the dynamic shared memory implementation used.

(5 rows)

I haven't set an index or stored embeddings here. This example simply demonstrates how it works in a stateless demo. The similarity search uses the cosine distance operator <=> to compare data from pg_settings with my prompt, returning the Top-5 matches. In a real application, you would generate the embeddings within an AI pipeline, then store and index them using pg_vector or DiskANN, rather than calling azure_openai.create_embeddings() on each query, except for the prompt.

AzureAI also provides functions for re-ranking the results of a similarity search. Ideally, a dedicated ranking model should be used. Here, I didn't deploy one and used the chat model for demonstration.:

postgres=> WITH
 settings AS (
  SELECT name, short_desc, row_to_json(pg_settings)::text AS doc,
      azure_openai.create_embeddings( 'default-embedding',
        row_to_json(pg_settings)::text
      )::vector AS embedding FROM pg_settings),
 candidates as (
  SELECT name, short_desc, doc FROM settings
  ORDER BY embedding <=> azure_openai.create_embeddings( 'default-embedding',
    'Buffer cache shared memory allocated by PostgreSQL'
  )::vector LIMIT 20 ),
 reranked AS ( SELECT * FROM azure_ai.rank(
    'Setting the buffer cache shared memory allocated by PostgreSQL',
    ARRAY (SELECT doc FROM candidates),  -- text to rank
    ARRAY (SELECT name FROM candidates), -- id of the item
    'default-chat'
  )
)
SELECT r.*, s.short_desc
 FROM settings s JOIN reranked r ON r.id = s.name
 ORDER BY r.rank LIMIT 5
;

             id             | rank | score |                                       short_desc
----------------------------+------+-------+----------------------------------------------------------------------------------------
 shared_buffers             |    1 |   0.9 | Sets the number of ... (truncated)