a curated list of database news from authoritative sources

October 04, 2026

The Oracle FETCH FIRST story: from ROW_NUMBER() in 12c back to ROWNUM in 23ai

The FETCH FIRST ... ROWS ONLY clause arrived in the SQL standard with SQL:2008, and Oracle Database implemented it in 12cR1, released in June 2013.

Before that, there were two common ways to write a Top-N query, both requiring a subquery:

  • Order the rows first, then apply ROWNUM outside:
SELECT *
FROM (
  SELECT ...
  FROM ...
  ORDER BY ...
)
WHERE ROWNUM <= 42;
  • Calculate an analytic ROW_NUMBER(), then filter its result outside:
SELECT *
FROM (
  SELECT ...,
         ROW_NUMBER() OVER (ORDER BY ...) AS rn
  FROM ...
)
WHERE rn <= 42;

The first subquery is necessary because the ordering must happen before the ROWNUM filter. The second is necessary because the analytic function cannot be evaluated in the same query block’s WHERE clause.

With FETCH FIRST, we can express the intention directly:

SELECT ...
FROM ...
ORDER BY ...
FETCH FIRST 42 ROWS ONLY;

However, a simpler SQL statement does not necessarily mean a simpler implementation.

First, a transformation to ROW_NUMBER

Like many additions to Oracle’s SQL syntax, FETCH FIRST was implemented through a transformation to existing constructs. Oracle initially chose the analytic ROW_NUMBER() solution.

This was the familiar rewrite in 12c, 18c, 19c, 21c, and early 23c releases. In the recent 23ai and 26ai releases tested here, the simple FETCH FIRST n ROWS ONLY case is rewritten using ROWNUM.

“AI” is not responsible for the change. Oracle 18c and 19c belong to the 12cR2 release family, with names reflecting the release year. Similarly, 26ai is still in the 23 release family, and “ai” replaced “c” with the 23.4 release.

Beyond the marketing names, the relevant change is fix control 35915968:

SQL> SELECT bugno, description, optimizer_feature_enable
     FROM v$system_fix_control
     WHERE bugno = 35915968;

     BUGNO DESCRIPTION                                           OPTIMIZER_FEATURE_ENABLE
---------- ----------------------------------------------------- ------------------------
  35915968 fetch first transformation using rownum                23.1.0

The OPTIMIZER_FEATURE_ENABLE column tells us the optimizer compatibility setting associated with the control.

There is another clue in ORACLE_HOME. In the 26ai installation used for this investigation, rdbms/admin/bundlefcp_DBBP.xml lists this control under the 23.4.0.0.0 bundle:

<bug id="35915968">
  <fix_control default_value="1">35915968</fix_control>
</bug>

This is more useful than simply saying “23ai changed it.”

The first problem was costing

Choosing ROW_NUMBER() rather than ROWNUM already had a consequence in 12cR1: the optimizer did not apply the same first-k-row optimization.

I blogged about this in 2014: ROWNUM vs ROW_NUMBER() and 12c fetch first.

With ROWNUM <= 10, Oracle knew that it needed only the first ten rows and could cost an ordered index access accordingly. With the analytic rewrite, it could cost the access as if many more rows were needed, making a full scan and sort look preferable.

My recommendation was to add FIRST_ROWS(n), not the old FIRST_ROWS hint.

This was improved in 19c:

SQL> SELECT bugno, description, optimizer_feature_enable
     FROM v$system_fix_control
     WHERE bugno = 22174392;

     BUGNO DESCRIPTION                                                      OPTIMIZER_FEATURE_ENABLE
---------- ---------------------------------------------------------------- ------------------------
  22174392 first k row optimization for window function rownum predicate    19.1.0

I blogged about it in 2020: 19c: scalable Top-N queries without further hints to the query planner.

The improvement is visible in the execution plan as WINDOW NOSORT STOPKEY, the stopkey replacing the simple analytic filter WINDOW SORT PUSHED RANK, so that Oracle could cost the analytic Top-N query with the first-k-row objective and choose that access without the additional hint.

When an index supplies the required order, Oracle can stop early. Without an ordered access path, a sort may still be necessary.

Now, back to ROWNUM

The first-k-row is not the only difference between ROWNUM and the analytic function. ROWNUM was also acts as a non-mergeable view barrier, forcing the optimizer to treat the query block as an isolated inline view.

For the simple row limit tested here, Oracle 26ai now goes back to the other implementation:

SQL> EXPLAIN PLAN FOR
     SELECT *
     FROM dual
     ORDER BY dummy
     FETCH FIRST 42 ROWS ONLY;

SQL> SELECT *
     FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'BASIC +PREDICATE'));

------------------------------------
| Id  | Operation           | Name |
------------------------------------
|   0 | SELECT STATEMENT    |      |
|*  1 |  COUNT STOPKEY      |      |
|   2 |   VIEW              |      |
|   3 |    TABLE ACCESS FULL| DUAL |
------------------------------------

Predicate Information:
----------------------

   1 - filter(ROWNUM<=42)

The transformation is visible with DBMS_UTILITY.EXPAND_SQL_TEXT:

WITH
  FUNCTION expand_my_sql(p_sql IN VARCHAR2) RETURN CLOB IS
    v_output CLOB;
  BEGIN
    DBMS_UTILITY.EXPAND_SQL_TEXT(
      input_sql_text  => p_sql,
      output_sql_text => v_output
    );
    RETURN v_output;
  END;
SELECT expand_my_sql(
  'SELECT * FROM DUAL ORDER BY DUMMY FETCH FIRST 42 ROWS ONLY'
) AS expanded_sql
/

Formatted for readability, the output is:

SELECT "A1"."DUMMY" "DUMMY"
FROM (
  SELECT "A2"."DUMMY" "DUMMY"
  FROM "SYS"."DUAL" "A2"
  ORDER BY "A2"."DUMMY"
) "A1"
WHERE ROWNUM <= 42

We can restore the earlier transformation on the same database by disabling the change:

ALTER SESSION SET "_fix_control" = '35915968:0';

The expanded SQL becomes:

SELECT "A1"."DUMMY" "DUMMY"
FROM (
  SELECT "A2"."DUMMY" "DUMMY",
         "A2"."DUMMY" "rowlimit_$_0",
         ROW_NUMBER() OVER (
           ORDER BY "A2"."DUMMY"
         ) "rowlimit_$$_rownumber"
  FROM "SYS"."DUAL" "A2"
) "A1"
WHERE "A1"."rowlimit_$$_rownumber" <= 42
ORDER BY "A1"."rowlimit_$_0"

And the plan uses the analytic operation again:

---------------------------------------
| Id  | Operation             | Name |
---------------------------------------
|   0 | SELECT STATEMENT      |      |
|*  1 |  VIEW                 |      |
|*  2 |   WINDOW NOSORT STOPKEY|      |
|   3 |    TABLE ACCESS FULL  | DUAL |
---------------------------------------

Predicate Information:
----------------------

   1 - filter("from$_subquery$_002"."rowlimit_$$_rownumber"<=42)
   2 - filter(ROW_NUMBER() OVER (ORDER BY "DUAL"."DUMMY")<=42)

More than a decade after introducing FETCH FIRST, Oracle is using the legacy ROWNUM solution for this case. It benefits from the existing first-k-row optimization and gives the optimizer a well-established row-limit boundary.

This is not a claim that all row-limiting clauses now use ROWNUM. OFFSET, percentages, and WITH TIES have additional semantics. In the same 26ai binary, another control, 35969400, can also select a native ROW LIMIT operation.

But for the case discussed here, going back to ROWNUM also avoids a wrong-results bug.

EXISTS and NOT EXISTS returning a row

A Stack Overflow question showed Oracle returning a row for this query:

SELECT 1 AS one_row
FROM dual
WHERE EXISTS (SELECT * FROM view_abcd)
  AND NOT EXISTS (SELECT * FROM view_abcd)
;

This is a logical contradiction. EXISTS returns true or false depending on whether its subquery returns any rows, regardless of the values in those rows. If one predicate is true, the other must be false.

The view combines OUTER APPLY, an inner ANSI JOIN, and a correlated FETCH FIRST 1 ROW ONLY. The question reports the problem on 19c and 21c.

On Oracle 23ai Free 23.9.0.25.07, the default result is correct. Disabling 35915968 makes it return the wrong row: db<>fiddle.

I also reproduced it on 26ai Free 23.26.3.0.0 with:

ALTER SESSION SET optimizer_features_enable = '21.1.0';

That was a starting point, not the solution. I wanted a finer setting than changing the whole optimizer compatibility level.

Copilot, guided by experience

I used Copilot for this investigation. Not just to ask “what is the answer?”, but to run the experiments I would normally run myself.

Here are some of my prompts:

“Please find the bug published by Oracle.”

“Is it ANSI join? There’s a long history of ANSI joins implemented as transformations and not working.”

“Reproduce and use Pathfinder to find the exact parameter or bug control to avoid it.”

“Maybe you have run without the latest patches.”

“I’m able to reproduce the bug on 26ai with optimizer_features_enable='21.1.0', but I would like a finer setting.”

The agent ran Mauro Pagano’s Pathfinder with OFE 21.1 as the baseline, testing 2,811 cases. I described this method in the past (I ‘fixed’ execution plan regression with optimizer_features_enable, what to do next?).

Two controls avoided the wrong result: 35915968:1 and 35969400:1. The description of the first immediately connected the result to the FETCH FIRST rewrite.

Then I prompted Copilot for the next step:

“Look at the execution plans.”

“Is it a combination of FETCH FIRST transformation, ANSI join, and merged views?”

This is where AI assistance is useful to me. My experience guides the investigation. The agent handles the repetitive experiments, collects the output, and helps compare it. The results still have to explain the behavior. A plausible answer is not enough.

What the trace adds

The failing execution plan is reduced to an unconditional FAST DUAL. The correct plan retains the filter, the outer-join branches, and the COUNT STOPKEY operations.

The expanded SQL and execution plans show the difference. The paired 10053 traces add something more interesting: the analytic and ROWNUM rewrites lead to different view-merging decisions.

In the analytic path, Oracle keeps the window-function query block:

SVM:     SVM bypassed on view SEL$4(#0): Window functions in this view.

It also retains an additional correlated lateral-view layer:

SVM:     SVM bypassed on view SEL$F23444D6(#0): Lateral view with left correlation.

In the ROWNUM path, Oracle merges the simple inner query into the block containing the row limit:

CVM:   Merging SPJ view SEL$4 (#0) into SEL$6 (#0)

The resulting scalar subquery is simpler:

SELECT "D"."DUMMY"
FROM "SYS"."DUAL" "D"
WHERE ROWNUM <= 1
  AND "D"."DUMMY" = "A"."DUMMY"

It also merges a layer that remained separate in the analytic path:

CVM:   Merging SPJ view SEL$F23444D6 (#0) into SEL$3 (#0)

So the explanation is not simply “ROWNUM prevents view merging.” The correct path actually performs more merges at these points. It removes unnecessary wrapper blocks while retaining the scalar subquery’s row-limit boundary.

Both paths still have restrictions around the null-augmented outer-joined lateral view. The analytic path adds another combination of boundaries and correlations, and that combination ends in the wrong executable plan.

This identifies the problematic path. It does not identify the exact line of Oracle code responsible, nor prove that 35915968 was originally created to fix this particular report. What we have demonstrated is that enabling this transformation avoids the wrong result.

New syntax, old machinery

I like standard SQL syntax. FETCH FIRST expresses the intention more clearly than a manually nested ROWNUM query. I've seen too many of those queries with the wrong placement of ORDER BY.

But syntax support and native implementation are different things.

Oracle’s ANSI joins are translated into internal structures, including lateral views and legacy outer-join representations. The row-limit clause was translated into an analytic query. Each transformation may be reasonable on its own. Their combinations are where the complexity grows.

There is a long history of ANSI-related wrong-results bugs. Jonathan Lewis has documented examples involving ANSI joins and NATURAL JOIN. I've see it striking again with materialized views and assertions.

Transformations are essential to query optimization. The problem is not that they exist. It is that adding a feature through several layers of rewriting also adds interactions that must preserve the original semantics.

Here, the legacy ROWNUM implementation has two advantages: first-k-row optimization and a row-limit boundary that Oracle already knows how to handle. Finally, the newer syntax stayed but the machinery underneath returned to the simpler implementation.

And AI can help us investigate that machinery. Not by replacing database knowledge with a confident explanation, but by making it easier to run the experiments that turn an explanation into evidence. You don't know to download Pathfinder and run it yourself when an AI agent can reproduce everything on a docker container.

What about PostgreSQL?

You may wonder how PostgreSQL dealt with the addition of FETCH FIRST to the SQL standard. Unlike Oracle’s initial implementation, PostgreSQL did not rewrite it into an analytic ROW_NUMBER() query. FETCH FIRST was implemented as an alternative syntax for LIMIT/OFFSET which already existed, and EXPLAIN shows a Limit node. The planner also knows the requested row count when costing paths: if an index supplies the required order, it can choose that path and stop once enough rows have been produced. Otherwise, it may still need to scan or sort more data.

At the top level, FETCH FIRST does not add a query block. Inside a subquery, though, the row limit prevents that subquery from being pulled up, since moving the limit could change which rows it returns. So PostgreSQL has a native stop-after-N boundary, conceptually like Oracle’s COUNT STOPKEY—not the same operator, but the same basic idea. The engines converge on that approach. PostgreSQL used it from the start, while Oracle initially implemented FETCH FIRST through ROW_NUMBER() analytic filter and later changed the rewrite for the simple row-limit case.

October 02, 2026

Supabase Select 2026 Recap

Build anything, operate with confidence, scale without limits. Supabase carries your application from prototype to petabyte.

Operate with confidence

More data, more tools and more control so your agent has what it needs to observe your project, investigate issues and propose fixes, autonomously.

October 01, 2026

DocumentDB 0.117: scalar $group index pushdown

Here's a detailed look at scalar $group index pushdown, an optimization introduced with DocumentDB 0.117-0 on September 10, 2026. It also highlights an advantage over MongoDB: the PostgreSQL query planner ensures optimal performance without requiring additional hints in the query.

DocumentDB is a fully open-source PostgreSQL extension that brings the MongoDB API to the SQL table. It offers MongoDB users an alternative by integrating with PostgreSQL, transforming MongoDB operators into efficient PostgreSQL access paths. Microsoft leads the development, continuously enhancing the extension based on valuable feedback from enterprise customers on Azure DocumentDB.

To demonstrate the optimization, I load 50,000 documents with 100 distinct a values and 200-byte payloads. I create an index on {a: 1} then calculate {$group: {_id: null, total: {$sum: "$a"}}}. The query has no hint, so the optimizer must decide whether it can answer the accumulator from the a_1 index.

Controlled method

MongoDB, DocumentDB 0.116, and DocumentDB 0.117 process the same documents, indexes, and unhinted pipelines. Each DocumentDB gateway execution is divided into setup, VACUUM (ANALYZE), and query phases. I compare rows, heap fetches, and buffers rather than elapsed time across different containers.

The feature is present in 0.117 but disabled by default. I enable only documentdb.enable_scalar_aggregate_index_pushdown. All other settings stay the same.

MongoDB 8.0 reference

I initially perform the unhinted aggregation on MongoDB Atlas 8.0:

db = db.getSiblingDB("perf117");
db.scalar_group.drop();
db.scalar_group.insertMany(Array.from({length: 50000}, (_, index) => ({
  _id: index + 1,
  a: (index + 1) % 100,
  payload: "x".repeat(200)
})));
db.scalar_group.createIndex({a: 1});

const pipeline = [
  {$group: {_id: null, total: {$sum: "$a"}}}
];
print(EJSON.stringify({
  result: db.scalar_group.aggregate(pipeline).toArray(),
  explain: db.scalar_group.explain("executionStats").aggregate(pipeline)
}, null, 2));

{
  "result": [
    {
      "_id": null,
      "total": 2475000
    }
  ],
  "explain": {
    "explainVersion": "2",
    "queryPlanner": {
      "namespace": "perf117.scalar_group",
      "parsedQuery": {},
      "indexFilterSet": false,
      "queryHash": "7234A6DE",
      "planCacheShapeHash": "7234A6DE",
      "planCacheKey": "2A6F009E",
      "optimizationTimeMillis": 0,
      "optimizedPipeline": true,
      "maxIndexedOrSolutionsReached": false,
      "maxIndexedAndSolutionsReached": false,
      "maxScansToExplodeReached": false,
      "prunedSimilarIndexes": false,
      "winningPlan": {
        "isCached": false,
        "queryPlan": {
          "stage": "GROUP",
          "planNodeId": 3,
          "inputStage": {
            "stage": "COLLSCAN",
            "planNodeId": 1,
            "filter": {},
            "direction": "forward"
          }
        },
        "slotBasedPlan": {
          "slots": "$$RESULT=s8 env: {  }",
          "stages": "[3] project [s8 = newBsonObj(\"_id\", s6, \"total\", s7)] \n[3] project [s6 = null, s7 = doubleDoubleSumFinalize(s5)] \n[3] group [] [s5 = aggDoubleDoubleSum(s1)] spillSlots[s4] mergingExprs[aggMergeDoubleDoubleSums(s4)] \n[1] scan s2 s3 none none none none none none lowPriority [s1 = a] @\"a05943ac-772f-4c76-b128-e6e3bfdd0158\" true false "
        }
      },
      "rejectedPlans": []
    },
    "executionStats": {
      "executionSuccess": true,
      "nReturned": 1,
      "executionTimeMillis": 12,
      "totalKeysExamined": 0,
      "totalDocsExamined": 50000,
      "executionStages": {
        "stage": "project",
        "planNodeId": 3,
        "nReturned": 1,
        "executionTimeMillisEstimate": 4,
        "opens": 1,
        "closes": 1,
        "saveState": 0,
        "restoreState": 0,
        "isEOF": 1,
        "projections": {
          "8": "newBsonObj(\"_id\", s6, \"total\", s7) "
        },
        "inputStage": {
          "stage": "project",
          "planNodeId": 3,
          "nReturned": 1,
          "executionTimeMillisEstimate": 4,
          "opens": 1,
          "closes": 1,
          "saveState": 0,
          "restoreState": 0,
          "isEOF": 1,
          "projections": {
            "6": "null ",
            "7": "doubleDoubleSumFinalize(s5) "
          },
          "inputStage": {
            "stage": "group",
            "planNodeId": 3,
            "nReturned": 1,
            "executionTimeMillisEstimate": 4,
            "opens": 1,
            "closes": 1,
            "saveState": 0,
            "restoreState": 0,
            "isEOF": 1,
            "groupBySlots": [],
            "expressions": {
              "5": "aggDoubleDoubleSum(s1) ",
              "initExprs": {
                "5": null
              }
            },
            "mergingExprs": {
              "4": "aggMergeDoubleDoubleSums(s4) "
            },
            "usedDisk": false,
            "spills": 0,
            "spilledBytes": 0,
            "spilledRecords": 0,
            "spilledDataStorageSize": 0,
            "inputStage": {
              "stage": "scan",
              "planNodeId": 1,
              "nReturned": 50000,
              "executionTimeMillisEstimate": 4,
              "opens": 1,
              "closes": 1,
              "saveState": 0,
              "restoreState": 0,
              "isEOF": 1,
              "numReads": 50000,
              "recordSlot": 2,
              "recordIdSlot": 3,
              "scanFieldNames": [
                "a"
              ],
              "scanFieldSlots": [
                1
              ]
            }
          }
        }
      }
    },
    "queryShapeHash": "F1620CAE8E90491891C0FB6F23ECD9556823F83467253A897C540C2A88AC66E5",
    "command": {
      "aggregate": "scalar_group",
      "pipeline": [
        {
          "$group": {
            "_id": null,
            "total": {
              "$sum": "$a"
            }
          }
        }
      ],
      "cursor": {},
      "$db": "perf117"
    },
    "serverInfo": {
      "host": "a004f7434a57",
      "port": 27017,
      "version": "8.0.28",
      "gitVersion": "cd6fc9b3b7cf87ff2bbca0af67382ac407fc682a"
    },
    "serverParameters": {
      "internalQueryFacetBufferSizeBytes": 104857600,
      "internalQueryFacetMaxOutputDocSizeBytes": 104857600,
      "internalLookupStageIntermediateDocumentMaxSizeBytes": 104857600,
      "internalDocumentSourceGroupMaxMemoryBytes": 104857600,
      "internalQueryMaxBlockingSortMemoryUsageBytes": 104857600,
      "internalQueryProhibitBlockingMergeOnMongoS": 0,
      "internalQueryMaxAddToSetBytes": 104857600,
      "internalDocumentSourceSetWindowFieldsMaxMemoryBytes": 104857600,
      "internalQueryFrameworkControl": "trySbeRestricted",
      "internalQueryPlannerIgnoreIndexWithCollationForRegex": 1
    },
    "ok": 1
  }
}

MongoDB performs a COLLSCAN for this unhinted scalar aggregation, scanning all 50,000 documents without using any index keys, resulting in one group. The aggregation does not spill, but the documents containing payload data are still read.

MongoDB creates index-access plans only when the index helps with filtering, sorting, or a DISTINCT_SCAN rewrite, which isn't the case here. To force an IXSCAN for this query, you need to specify the index explicitly using { hint: { a: 1 } } (this bypasses the query planner so you must ensure that the result remains unaffected, especially if using a partial index):

db.scalar_group.explain(
   "executionStats"
 ).aggregate(
   pipeline, { hint: { a: 1 } } 
 ).queryPlanner.winningPlan
;

{
  isCached: false,
  queryPlan: {
    stage: 'GROUP',
    planNodeId: 3,
    inputStage: {
      stage: 'PROJECTION_COVERED',
      planNodeId: 2,
      transformBy: { a: true, _id: false },
      inputStage: {
        stage: 'IXSCAN',
        planNodeId: 1,
        keyPattern: { a: 1 },
        indexName: 'a_1',
        isMultiKey: false,
        multiKeyPaths: { a: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: { a: [ '[MinKey, MaxKey]' ] }
      }
    }
  },
  slotBasedPlan: {
    slots: '$$RESULT=s7 env: {  }',
    stages: '[3] project [s7 = newBsonObj("_id", s5, "total", s6)] \n' +
      '[3] project [s5 = null, s6 = doubleDoubleSumFinalize(s4)] \n' +
      '[3] group [] [s4 = aggDoubleDoubleSum(s1)] spillSlots[s3] mergingExprs[aggMergeDoubleDoubleSums(s3)] \n' +
      '[1] ixseek KS(0A0104) KS(F0FE04) none s2 none none lowPriority [s1 = 0] @"10eeeb21-169c-4c72-bbb4-4a674cbf37db" @"a_1" true '
  }
}

With the hint, MongoDB can use an index-only scan (IXSCAN + PROJECTION_COVERED), reading only 50,000 index entries.

DocumentDB 0.116 (before this optimization)

I load and index the same data through the DocumentDB 0.116 gateway (MongoDB-compatible endpoint):

db = db.getSiblingDB("perf117");
db.scalar_group.drop();
db.scalar_group.insertMany(Array.from({length: 50000}, (_, index) => ({
  _id: index + 1,
  a: (index + 1) % 100,
  payload: "x".repeat(200)
})));
db.scalar_group.createIndex(
  {a: 1},
  {storageEngine: {enableOrderedIndex: true}}
);
print(EJSON.stringify({
  insertedDocuments: db.scalar_group.countDocuments(),
  indexes: db.scalar_group.getIndexes().map(index => index.name)
}, null, 2));

{
  "insertedDocuments": 50000,
  "indexes": [
    "_id_",
    "a_1"
  ]
}

I run VACUUM (ANALYZE) in PostgreSQL to simulate the auto-vacuum job in a reproducible way:

VACUUM (ANALYZE)
;

Then I run the unchanged scalar aggregation:

db = db.getSiblingDB("perf117");
const pipeline = [
  {$group: {_id: null, total: {$sum: "$a"}}}
];
print(EJSON.stringify({
  result: db.scalar_group.aggregate(pipeline).toArray(),
  explain: db.scalar_group.explain("executionStats").aggregate(pipeline)
}, null, 2));

{
  "result": [
    {
      "_id": null,
      "total": 2475000
    }
  ],
  "explain": {
    "explainVersion": 2,
    "command": "db.runCommand({explain: { 'aggregate': 'scalar_group', 'pipeline': [{ '$group': { '_id': null, 'total': { '$sum': '$a' } } }], 'cursor': {} }})",
    "explainCommandPlanningTimeMillis": 4.189,
    "explainCommandExecTimeMillis": 159.291,
    "stages": [
      {
        "$cursor": {
          "queryPlanner": {
            "winningPlan": {
              "stage": "COLLSCAN",
              "startupCost": 0,
              "totalCost": 2477,
              "estimatedTotalKeysExamined": 50000
            }
          },
          "executionStats": {
            "nReturned": 50000,
            "executionTimeMillis": 67.959,
            "executionStartAtTimeMillis": 0.004,
            "totalDocsExamined": 50000,
            "totalKeysExamined": 50000,
            "executionStages": {
              "stage": "COLLSCAN",
              "nReturned": 50000,
              "executionTimeMillis": 67.959,
              "executionStartAtTimeMillis": 0.004,
              "totalDocsExamined": 50000,
              "totalKeysExamined": 50000,
              "numBlocksFromCache": 1852
            }
          }
        }
      },
      {
        "$root": {
          "queryPlanner": {
            "winningPlan": {
              "stage": "GENERIC_AGGREGATE",
              "startupCost": 0,
              "totalCost": 2602.02,
              "aggStrategy": "Sorted",
              "estimatedTotalKeysExamined": 1
            }
          },
          "executionStats": {
            "nReturned": 1,
            "executionTimeMillis": 159.235,
            "executionStartAtTimeMillis": 159.231,
            "totalDocsExamined": 1,
            "totalKeysExamined": 1,
            "executionStages": {
              "stage": "GENERIC_AGGREGATE",
              "nReturned": 1,
              "executionTimeMillis": 159.235,
              "executionStartAtTimeMillis": 159.231,
              "totalDocsExamined": 1,
              "totalKeysExamined": 1,
              "numBlocksFromCache": 1852
            }
          }
        }
      }
    ],
    "ok": 1
  }
}

DocumentDB 0.116 also performs a collection scan through the gateway. It examines 50,000 documents and reports 1,852 shared-buffer hits.

I run the same setup and pipeline through the native DocumentDB API in the PostgreSQL endpoint, but explicitly disable Seq Scan to show why the Index Scan is more expensive:

\pset pager off
\set ON_ERROR_STOP on
SET search_path TO documentdb_api, documentdb_core, documentdb_api_catalog,
  documentdb_api_internal, public;

SELECT extversion AS documentdb_version
FROM pg_extension
WHERE extname = 'documentdb';

SELECT documentdb_api.drop_collection('perf117', 'scalar_group');
SELECT count(documentdb_api.insert_one(
  'perf117',
  'scalar_group',
  format(
    '{"_id":%s,"a":%s,"payload":"%s"}',
    i, i % 100, repeat('x', 200)
  )::documentdb_core.bson,
  NULL
))
FROM generate_series(1, 50000) i;

SELECT documentdb_api_internal.create_indexes_non_concurrently(
  'perf117',
  '{"createIndexes":"scalar_group","indexes":[{"key":{"a":1},"storageEngine":{"enableOrderedIndex":true},"name":"a_1"}]}',
  true
);

SELECT collection_id
FROM documentdb_api_catalog.collections
WHERE database_name = 'perf117' AND collection_name = 'scalar_group'
\gset
VACUUM (ANALYZE) documentdb_data.documents_:collection_id;

SET enable_seqscan TO off;
SET enable_bitmapscan TO off;

EXPLAIN (ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS)
SELECT document
FROM bson_aggregation_pipeline(
  'perf117',
  '{"aggregate":"scalar_group","pipeline":[{"$group":{"_id":null,"total":{"$sum":"$a"}}}],"cursor":{}}'
);

...
                                                                                                                             QUERY PLAN                                                                                                                             
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate (actual rows=1 loops=1)
   Output: bson_repath_and_build('_id'::text, 'BSONHEX070000000a0000'::bson, 'total'::text, bsonsumwithexpr(document, 'BSONHEX0e00000002000300000024610000'::bson, 'BSONHEX12000000096e6f7700876814cda001000000'::bson, NUL... (truncated)
                                    

Working with foreign key constraints in Aurora DSQL

Amazon Aurora DSQL supports foreign key constraints, letting you enforce referential integrity directly in the database. This post covers defining foreign keys, immediate versus deferred enforcement, adding constraints to existing tables, and how optimistic concurrency control resolves conflicts in distributed workloads.

Tuning Percona ClusterSync for MongoDB Performance

Percona ClusterSync for MongoDB (PCSM) synchronizes data from a source MongoDB cluster (including Atlas) to a target MongoDB cluster in two stages. It first creates a consistent starting point and clones the existing collections and indexes. At the same time, it captures source changes through MongoDB change streams. Once the initial copy is complete, PCSM … Continued

The post Tuning Percona ClusterSync for MongoDB Performance appeared first on Percona.

Designing Neki for performance

How Neki only decodes the parts of a PostgreSQL result the router actually needs.

September 30, 2026

AWS and AMD bring 5th generation AMD EPYC processors to Amazon Aurora and Amazon RDS

AWS today announced the general availability of AMD-based R8a and M8a database instances for Amazon Aurora and Amazon RDS, powered by 5th generation AMD EPYC processors. R8a is a memory-optimized instance family available on both Aurora and RDS. M8a is a general-purpose instance family available on RDS.

Percona ClusterSync for MongoDB Goes Highly Available: Active-Standby Failover

Editor’s Note: This article was originally authored by Inel Pandzic. Migrating data between MongoDB clusters is rarely a five-minute job. A large initial clone followed by days of change-stream replication is normal, and for all that time, Percona ClusterSync for MongoDB (PCSM) is a critical piece of your infrastructure. Until now, it was also a … Continued

The post Percona ClusterSync for MongoDB Goes Highly Available: Active-Standby Failover appeared first on Percona.

Resolving PostgreSQL replication lag with heartbeat tables in change data capture scenarios

Replication lag during a change data capture (CDC) migration can be counterintuitive: the source is busy, yet replication falls behind and WAL piles up. This post shows how to resolve PostgreSQL replication lag with heartbeat tables using AWS DMS or Debezium, and how to monitor replication slot health with Amazon CloudWatch and SQL diagnostics.

Migrate Db2 z/OS to Amazon Aurora PostgreSQL using AWS DMS and gateway server

Learn how to use AWS DMS and a Db2 gateway server on Amazon EC2 to migrate and replicate data from an on-premises IBM Db2 database on z/OS to Amazon Aurora PostgreSQL. This post covers configuring the gateway, creating DMS resources, running a full load with periodic full-load refresh, and validating the migration.

September 29, 2026

DocumentDB 0.109: $match $sort $limit $project covered by index scan

This should have been the first post of this series. A few months ago, I demonstrated (video) what I see as one of the key advantages of a document model: a compound index can support filtering, sorting, and pagination across data embedded in a one-to-many relationship.

In a normalized relational model, that relationship typically spans multiple tables. Indexes belong to individual tables, so answering the same query may require to join more rows before sorting and filtering.

That demo used MongoDB. Some MongoDB emulations on SQL databases pretend compatibility but lack performance it they don't implement MongoDB-style indexing or query plans. That's generally because the emulation is built on top of RDBMS indexes which were not designed for non-1NF schemas.

Azure DocumentDB takes a different approach: it runs as a PostgreSQL extension which, thanks to PostgreSQL’s extensibility, can define native indexing for non-relational datatypes. The DocumentDB extension uses Extended RUM indexes.

In this post, I’ll run the same as I did on MongoDB to show that DocumentDB on PostgreSQL provides the same performance with similar execution plan. You can also play with it on db<>fiddle https://dbfiddle.uk/Ft_2L34U:

This starts by creating, though the MongoDB-compatible endpoint, the collection and index with accounts and operations:

//
// Create same data as https://youtu.be/Hq9CFhxSqgw?si=g6CnDuv_PqPHRquc
//

db.accounts.createIndex({
  category: 1,
  "operations.date": -1,
});

function insert(num) {
  const ops = [];
  for (let i = 0; i < num; i++) {
    const account = Math.floor(Math.random() * 10_000) + 1;
    const category = Math.floor(Math.random() * 3);
    const operation = {
      date: new Date(),
      amount: Math.floor(Math.random() * 1_000) + 1,
    };
    ops.push({
      updateOne: {
        filter: { _id: account },
        update: {
          $set: { category: category },
          $push: { operations: operation },
        },
        upsert: true,
      },
    });
  }
  db.accounts.bulkWrite(ops);
}

insert(1_000); insert(1_000); insert(1_000); insert(1_000); insert(1_000);
insert(1_000); insert(1_000); insert(1_000); insert(1_000); insert(1_000);

As the account operations are embedded as an array for each account, a single index can serve filtering on account's attributes, like "category", and operation's attributes, like "date".

A simple query asks Which Category 1 account had the most recent activity? and this involves a filter on category and operation date:


db.accounts.find(
    { "category": 1 },
    { "operations.amount": 1, "operations.date": 1 }
  ).sort({ "operations.date": -1 }).limit(1);

This is typical of pagination, filtering a specific number of documents from an ordered result:

[
  {
    _id: 6115,
    operations: [
      { date: ISODate('2026-09-25T22:34:03.218Z'), amount: 320 },
      { date: ISODate('2026-09-25T22:34:05.739Z'), amount: 847 }
    ]
  }
]

The MongoDB-compatible execution plan shows that it didn't require a sort operation as the index scan provides the documents in the expected order:

db.accounts.find(
    { "category": 1 },
    { "operations.amount": 1, "operations.date": 1 }
  ).sort({ "operations.date": -1 }).limit(1).explain().queryPlanner.winningPlan

{
  stage: 'LIMIT',
  startupCost: 0,
  totalCost: 0.2,
  estimatedTotalKeysExamined: 1,
  inputStage: {
    stage: 'PROJECT',
    startupCost: 0,
    totalCost: 0.2,
    estimatedTotalKeysExamined: 1,
    inputStage: {
      stage: 'FETCH',
      ns: 'test.accounts',
      startupCost: 0,
      totalCost: 208.79,
      estimatedTotalKeysExamined: 1055,
      inputStage: {
        stage: 'IXSCAN',
        ns: 'test.accounts',
        indexName: 'category_1_operations.date_-1',
        direction: 'Forward',
        indexUsage: {
          indexKeyString: '{"category": 1,"operations.date": -1}',
          isMultiKey: true,
          bounds: [
            '["category": [1, 1], "operations.date": DESC(MinKey, MaxKey)]'
          ]
        },
        startupCost: 0,
        totalCost: 208.79,
        hasOrderBy: true,
        indexFilterSet: [ { category: { '$eq': 1 } } ],
        estimatedTotalKeysExamined: 1055
      }
    }
  }
}

I can run the same query, as an aggregation pipeline, on the PostgreSQL endpoint though DocumentDB functions:

EXPLAIN (COSTS OFF, ANALYZE ON, BUFFERS ON, VERBOSE ON)
SELECT document FROM documentdb_api_catalog.bson_aggregation_pipeline(
    'test',
    '{"aggregate": "accounts", "pipeline": [
  {"$match": {"category": 1}},
  {"$sort": {"operations.date": -1}},
  {"$limit": 1},
  {"$project": {"operations.amount": 1, "operations.date": 1}}
    ], "cursor": {}}'::documentdb_core.bson
);

                                                   QUERY PLAN
--------------------------------------------------------------------------
Subquery Scan on agg_stage_3 (actual time=0.111..0.112 rows=1.00 loops=1)
  Output: documentdb_api_internal.bson_dollar_project(agg_stage_3.document, 'BSONHEX31000000106f7065726174696f6e732e616d6f756e740001000000106f7065726174696f6e732e64617465000100000000'::documentdb_core.bson, 'BSONHEX12000000096e6f7700d660b4daa001000000'::documentdb_core.bson)
  Buffers: shared hit=5
  ->  Limit (actual time=0.101..0.101 rows=1.00 loops=1)
        Output: collection.document, (documentdb_api_catalog.bson_orderby(collection.document, 'BSONHEX1a000000106f7065726174696f6e732e6461746500ffffffff00'::documentdb_core.bson))
        Buffers: shared hit=5
        ->  Custom Scan (DocumentDBApiExplainQueryScan) (actual time=0.100..0.100 rows=1.00 loops=1)
              Output: collection.document, documentdb_api_catalog.bson_orderby(collection.document, 'BSONHEX1a000000106f7065726174696f6e732e6461746500ffffffff00'::documentdb_core.bson)
              namespaceName: test.accounts
              indexName: category_1_operations.date_-1
              indexKey: {"category": 1,"operations.date": -1}
              isMultiKey: true
              indexBounds: ["category": [1, 1], "operations.date": DESC(MinKey, MaxKey)]
              innerScanLoops: 1 loops
              scanType: ordered
              scanKeyDetails: key 1: [(isInequality: true, estimatedEntryCount: 116)]
              _id_: (startup cost=0.282, total cost=287.743, selectivity=1, correlation=0.750, estimated index pages loaded=100.00%, estimated total index entries=6328, boundary selectivity=1, num boundaries=0, estimated data pages loaded=0.00%)
              Buffers: shared hit=5
              ->  Index Scan using "category_1_operations.date_-1" on documentdb_data.documents_2 collection (actual time=0.055..0.055 rows=1.00 loops=1)
                    Output: collection.document
                    Index Cond: (collection.document OPERATOR(documentdb_api_catalog.@=) 'BSONHEX130000001063617465676f7279000100000000'::documentdb_core.bson)
                    Order By: (collection.document OPERATOR(documentdb_api_catalog.|-<>) 'BSONHEX1a000000106f7065726174696f6e732e6461746500ffffffff00'::documentdb_core.bson)
                    Index Searches: 0
                    Buffers: shared hit=5
Planning:
  Buffers: shared hit=518
Planning Time: 1.605 ms
Execution Time: 0.202 ms

The Index Scan covered the $match filter with Index Cond, the $sort with Order By, the $limit: 1 with rows=1.00 and the $projection with Output. There no sort or filter above it. The Custom Scan is a purely decorative wrapper that DocumentDB injects around the real index access path so that EXPLAIN can expose MongoDB-style index metadata, like isMultiKey: true and the indexBounds.

I got the remark that the planning time is huge here Planning Time: 1.605 ms. I've ran it on db<>fiddle which use micro-VM for fast ephemeral instances, so don't compare time. But still, Buffers: shared hit=518 as a lot and this is due to the first query in a PostgreSQL session that reads from the catalog. If I had run the query with explain first to check the result (https://dbfiddle.uk/eFaEQ0Gq), the next query would have had much faster planning:

Planning:
  Buffers: shared hit=2
Planning Time: 0.126 ms
Execution Time: 0.104 ms

The video demonstrated the main advantage of a document model with multi-key indexes on MongoDB. The same exists open-source on PostgreSQL with the DocumentDB extension. Extended RUM was added in v0.106 (August 29, 2025), ordered indexes/scans were enabled by default in v0.109 (March 09, 2026) - be sure that documentdb_extended_rum is listed in shared_preload_libraries - and later releases improved edge cases of it (e.g. collation support across 0.111–0.113, multikey fixes in 0.116).

DocumentDB 0.116: $sort $group prefix pushdown

DocumentDB 0.116-0, released August 20, 2026, introduced prefix sort pushdown for $group accumulators. It lets the grouping stage consume rows in the existing sort order, avoiding a separate blocking sort or hash aggregate.

The previous article in this series covered a different optimization: a distinct scan that skips duplicate index entries when a group returns one row per distinct key. Prefix sort pushdown does not skip entries. It removes the top-level sort. The query pattern is also related to the first-per-group example in First/Last per Group: PostgreSQL DISTINCT ON and MongoDB DISTINCT_SCAN Performance, which shows MongoDB using DISTINCT_SCAN.

DocumentDB implements the MongoDB API as a fully open-source PostgreSQL extension. It offers an open alternative for MongoDB applications and exposes aggregation execution through both MongoDB-like and PostgreSQL plans, providing insight into sorts, buffers, and temporary I/O. Microsoft is the main contributor, with improvements coming from enterprise experience with Azure DocumentDB.

To demonstrate prefix sort pushdown, I load 50,000 documents across 100 groups, add a 200-byte payload, and create an ordered index on {a: 1}. The pipeline sorts by a, groups by the same key, and returns the first name in each group. Because the group key is a prefix of the sort keys, the accumulator can use the existing index order instead of sorting all 50,000 rows again.

Controlled method

MongoDB, DocumentDB 0.114, and DocumentDB 0.116 receive the same data, ordered index, hint, and pipeline. Both DocumentDB versions get the same VACUUM (ANALYZE), and both native runs use the same planner settings. The documentdb.enableSortPushToAccumulatorWithPrefix setting exists in 0.114 but is disabled by default. It is enabled by default in 0.116. I compare explicit sort nodes, temporary I/O, rows, and buffers rather than total elapsed time. The only timing I cite is when the gateway's group stage starts producing rows (executionStartAtTimeMillis), which shows whether grouping blocks or streams. In the native (SQL function) runs, I set enable_hashagg=off (and enable_seqscan / enable_bitmapscan) to force the sorted plan, but the gateway (MongoDB-compatible endpoint) runs use default settings.

MongoDB 8.0 reference

I first run the pipeline on MongoDB Atlas 8.0:

db = db.getSiblingDB("perf116");
db.sort_group.drop();
db.sort_group.insertMany(Array.from({length: 50000}, (_, index) => ({
  _id: index + 1,
  a: (index + 1) % 100,
  b: (50000 - index - 1) % 1000,
  name: `name_${index + 1}`,
  payload: "x".repeat(200)
})));
db.sort_group.createIndex({a: 1});

const pipeline = [
  {$sort: {a: 1}},
  {$group: {_id: "$a", firstVal: {$first: "$name"}}}
];
print(EJSON.stringify({
  resultCount: db.sort_group.aggregate(
    pipeline,
    {hint: "a_1"}
  ).toArray().length,
  explain: db.sort_group.explain("executionStats").aggregate(
    pipeline,
    {hint: "a_1"}
  )
}, null, 2));

It shows a DISTINCT_SCAN:

{
  "resultCount": 100,
  "explain": {
    "explainVersion": "1",
    "stages": [
      {
        "$cursor": {
          "queryPlanner": {
            "namespace": "perf116.sort_group",
            "parsedQuery": {},
            "indexFilterSet": false,
            "queryHash": "DC5C6196",
            "planCacheShapeHash": "DC5C6196",
            "planCacheKey": "979F6906",
            "optimizationTimeMillis": 0,
            "maxIndexedOrSolutionsReached": false,
            "maxIndexedAndSolutionsReached": false,
            "maxScansToExplodeReached": false,
            "prunedSimilarIndexes": false,
            "winningPlan": {
              "isCached": false,
              "stage": "FETCH",
              "inputStage": {
                "stage": "DISTINCT_SCAN",
                "keyPattern": {
                  "a": 1
                },
                "indexName": "a_1",
                "isMultiKey": false,
                "multiKeyPaths": {
                  "a": []
                },
                "isUnique": false,
                "isSparse": false,
                "isPartial": false,
                "indexVersion": 2,
                "direction": "forward",
                "indexBounds": {
                  "a": [
                    "[MinKey, MaxKey]"
                  ]
                }
              }
            },
            "rejectedPlans": []
          },
          "executionStats": {
            "executionSuccess": true,
            "nReturned": 100,
            "executionTimeMillis": 2,
            "totalKeysExamined": 100,
            "totalDocsExamined": 100,
            "executionStages": {
              "isCached": false,
              "stage": "FETCH",
              "nReturned": 100,
              "executionTimeMillisEstimate": 0,
              "works": 101,
              "advanced": 100,
              "needTime": 0,
              "needYield": 0,
              "saveState": 3,
              "restoreState": 3,
              "isEOF": 1,
              "docsExamined": 100,
              "alreadyHasObj": 0,
              "inputStage": {
                "stage": "DISTINCT_SCAN",
                "nReturned": 100,
                "executionTimeMillisEstimate": 0,
                "works": 101,
                "advanced": 100,
                "needTime": 0,
                "needYield": 0,
                "saveState": 3,
                "restoreState": 3,
                "isEOF": 1,
                "keyPattern": {
                  "a": 1
                },
                "indexName": "a_1",
                "isMultiKey": false,
                "multiKeyPaths": {
                  "a": []
                },
                "isUnique": false,
                "isSparse": false,
                "isPartial": false,
                "indexVersion": 2,
                "direction": "forward",
                "indexBounds": {
                  "a": [
                    "[MinKey, MaxKey]"
                  ]
                },
                "keysExamined": 100
              }
            }
          }
        },
        "nReturned": 100,
        "executionTimeMillisEstimate": 3
      },
      {
        "$groupByDistinctScan": {
          "newRoot": {
            "_id": "$a",
            "firstVal": "$name"
          }
        },
        "nReturned": 100,
        "executionTimeMillisEstimate": 3
      }
    ],
    "queryShapeHash": "0822C17532FC5F85649D595163EF78A0BAEF5CDA8811F1689D203E1AD79FD2F7",
    "serverInfo": {
      "host": "a004f7434a57",
      "port": 27017,
      "version": "8.0.28",
      "gitVersion": "cd6fc9b3b7cf87ff2bbca0af67382ac407fc682a"
    },
    "serverParameters": {
      "internalQueryFacetBufferSizeBytes": 104857600,
      "internalQueryFacetMaxOutputDocSizeBytes": 104857600,
      "internalLookupStageIntermediateDocumentMaxSizeBytes": 104857600,
      "internalDocumentSourceGroupMaxMemoryBytes": 104857600,
      "internalQueryMaxBlockingSortMemoryUsageBytes": 104857600,
      "internalQueryProhibitBlockingMergeOnMongoS": 0,
      "internalQueryMaxAddToSetBytes": 104857600,
      "internalDocumentSourceSetWindowFieldsMaxMemoryBytes": 104857600,
      "internalQueryFrameworkControl": "trySbeRestricted",
      "internalQueryPlannerIgnoreIndexWithCollationForRegex": 1
    },
    "command": {
      "aggregate": "sort_group",
      "pipeline": [
        {
          "$sort": {
            "a": 1
          }
        },
        {
          "$group": {
            "_id": "$a",
            "firstVal": {
              "$first": "$name"
            }
          }
        }
      ],
      "hint": "a_1",
      "cursor": {},
      "$db": "perf116"
    },
    "ok": 1
  }
}

MongoDB recognizes the first-per-group pattern. It uses DISTINCT_SCAN on a_1, examines 100 index keys, fetches 100 documents for name, and returns 100 groups. There is no blocking sort.

DocumentDB 0.114 (before this optimization)

I load and index the same data through the DocumentDB 0.114 gateway (MongoDB-compatible endpoint):

db = db.getSiblingDB("perf116");
db.sort_group.drop();
db.sort_group.insertMany(Array.from({length: 50000}, (_, index) => ({
  _id: index + 1,
  a: (index + 1) % 100,
  b: (50000 - index - 1) % 1000,
  name: `name_${index + 1}`,
  payload: "x".repeat(200)
})));
db.sort_group.createIndex(
  {a: 1},
  {storageEngine: {enableOrderedIndex: true}}
);
print(EJSON.stringify({
  insertedDocuments: db.sort_group.countDocuments(),
  indexes: db.sort_group.getIndexes().map(index => index.name)
}, null, 2));


{
  "insertedDocuments": 50000,
  "indexes": [
    "_id_",
    "a_1"
  ]
}

Connected to the PostgreSQL endpoint, I make the table statistics and visibility state deterministic:

\pset pager off
SET search_path TO documentdb_api_catalog, public;
SELECT extversion AS documentdb_version
FROM pg_extension
WHERE extname = 'documentdb';
SELECT collection_id
FROM collections
WHERE database_name = 'perf116' AND collection_name = 'sort_group'
\gset
VACUUM (ANALYZE) documentdb_data.documents_:collection_id;
SELECT :'collection_id' AS vacuumed_collection_id;

 documentdb_version 
--------------------
 0.114-0
(1 row)

 vacuumed_collection_id 
------------------------
 2
(1 row)

Then I run the same hinted pipeline on the MongoSH:

db = db.getSiblingDB("perf116");
const pipeline = [
  {$sort: {a: 1}},
  {$group: {_id: "$a", firstVal: {$first: "$name"}}}
];
print(EJSON.stringify({
  resultCount: db.sort_group.aggregate(
    pipeline,
    {hint: "a_1"}
  ).toArray().length,
  explain: db.sort_group.explain("executionStats").aggregate(
    pipeline,
    {hint: "a_1"}
  )
}, null, 2));


{
  "resultCount": 100,
  "explain": {
    "explainVersion": 2,
    "command": "db.runCommand({explain: { 'aggregate': 'sort_group', 'pipeline': [{ '$sort': { 'a': 1 } }, { '$group': { '_id': '$a', 'firstVal': { '$first': '$name' } } }], 'hint': 'a_1', 'cursor': {} }})",
    "explainCommandPlanningTimeMillis": 2.649,
    "explainCommandExecTimeMillis": 349.424,
    "stages": [
      {
        "$cursor": {
          "queryPlanner": {
            "winningPlan": {
              "stage": "FETCH",
              "startupCost": 0,
              "totalCost": 13.89,
              "estimatedTotalKeysExamined": 5556,
              "inputStage": {
                "stage": "IXSCAN",
                "indexName": "a_1",
                "direction": "Forward",
                "startupCost": 0,
                "totalCost": 13.89,
                "hasOrderBy": true,
                "indexFilterSet": [
                  {
                    "a": {
                      "$range": {
                        "orderByScan": 1
                      }
                    }
                  }
                ],
                "estimatedTotalKeysExamined": 5556
              }
            }
          },
          "executionStats": {
            "nReturned": 50000,
            "executionTimeMillis": 125.407,
            "executionStartAtTimeMillis": 0.036,
            "totalDocsExamined": 50000,
            "totalKeysExamined": 50000,
            "executionStages": {
              "stage": "FETCH",
              "nReturned": 50000,
              "executionTimeMillis": 125.407,
              "executionStartAtTimeMillis": 0.036,
              "totalKeysExamined": 50000,
              "numBlocksFromCache": 50023,
              "inputStage": {
                "stage": "IXSCAN",
                "nReturned": 50000,
                "executionTimeMillis": 125.407,
                "executionStartAtTimeMillis": 0.036,
                "indexName": "a_1",
                "totalKeysExamined": 50000,
                "numBlocksFromCache": 50023
              }
            }
          }
        }
      },
      {
        "$group": {
          "queryPlanner": {
            "winningPlan": {
              "stage": "GROUP",
              "startupCost": 111.12,
              "totalCost": 236.13,
              "aggStrategy": "Hashed",
              "estimatedTotalKeysExamined": 5556,
              "inputStage": {
                "stage": "PROJECTION_DEFAULT",
                "startupCost": 0,
                "totalCost": 83.34,
                "estimatedTotalKeysExamined": 5556
              }
            }
          },
          "executionStats": {
            "nReturned": 100,
            "executionTimeMillis": 349.126,
            "executionStartAtTimeMillis": 348.869,
            "totalDocsExamined": 100,
            "totalKeysExamined": 100,
            "executionStages": {
              "stage": "GROUP",
              "nReturned": 100,
              "executionTimeMillis": 349.126,
              "executionStartAtTimeMillis": 348.869,
              "totalDocsExamined": 100,
              "totalKeysExamined": 100,
              "numBlocksFromCache": 50023,
              "inputStage": {
                "stage": "PROJECTION_DEFAULT",
                "nReturned": 50000,
                "executionTimeMillis": 255.141,
                "executionStartAtTimeMillis": 0.041,
                "totalDocsExamined": 50000,
                "totalKeysExamined": 50000,
                "numBlocksFromCache": 50023
              }
            }
          }
        }
      }
    ],
    "ok": 1
  }
}

With default settings, the gateway plan uses a hash aggregate (aggStrategy: "Hashed"), which cannot return any group until it reads all 50,000 rows. The group stage starts at 348.9 ms. The MongoDB-compatible explain doesn't show buffers or temporary I/O, so I inspect the native plan from the PostgreSQL endpoint. I disable hash aggregation there to see the sort-based plan that the new optimization targets:

\pset pager off
\set ON_ERROR_STOP on
SET search_path TO documentdb_api, documentdb_core, documentdb_api_catalog,
  documentdb_api_internal, public;

SELECT extversion AS documentdb_version
FROM pg_extension
WHERE extname = 'documentdb';

SELECT documentdb_api.drop_collection('perf116', 'sort_group');
SELECT count(documentdb_api.insert_one(
  'perf116',
  'sort_group',
  format(
    '{"_id":%s,"a":%s,"b":%s,"name":"name_%s","payload":"%s"}',
    i, i % 100, (50000 - i) % 1000, i, repeat('x', 200)
  )::documentdb_core.bson,
  
                                    
                                    
                                    
                                    
                                

Valkey Memory Optimization, Version by Version and Encoding: How Many Bytes Did Each Release Actually Save?

Introduction This post is based on a rigorous benchmark and a structural teardown of the source, aimed at answering three questions: For the same data, how much memory does each version actually save? Where exactly does that saving come from at the data-structure level? How do different data structures and encoding result in significantly different … Continued

The post Valkey Memory Optimization, Version by Version and Encoding: How Many Bytes Did Each Release Actually Save? appeared first on Percona.

Measuring the Performance of Deterministic Exceptions

I have always been interested in Zero-Overhead Deterministic Exceptions from P0709, as they promised a nicer implementation model and they are much easier to integrate into JIT-compiled code. Some people forbid regular C++ exceptions, due to code size and performance concerns, which are concerns that P0709 explicitly wants to address. Unfortunately, there was no implementation available. But now, with LLMs, doing this kind of experiment is much more tractable. I basically gave the official P0709 specification to an LLM, and it built a first prototype in one shot. Admittedly my initial enthusiasm cooled down a bit after I found a lot of problems and corner cases, but after about one day of work I was able to build a reasonable prototype, which is something that would easily have taken me weeks or even months in the past. I tested it with complex code, and it handled everything fine, but note that it is still not production ready (e.g., Itanium ABI only).

That prototype now allows us to quantify the differences between exception implementations. P0709 describes two ways to implement them: Either pass a hidden pointer to an error state to all functions that are marked with throws (we call this strategy pointer here), or use the carry bit to indicate errors and pass the error itself in registers if the bit is set (we call this strategy carry). That is a very nifty approach suggested by Herb Sutter, but it somewhat clashes with some of the function epilogues (e.g., on Windows), and such a flag is not available on all platforms. We thus introduced a third variant register here that simply uses another unused caller-saved register as an error indicator, which is more portable. The classic C++ exception handling mechanism we call dynamic.

Microbenchmarks

We first run some microbenchmarks to see the overall effects. These over-emphasize the differences as the benchmarks basically do nothing besides function calls (ns per outer call):

Test failures dynamic pointer register carry
int through 8 calls 0% 6.02 6.09 6.08 6.16
1% 17.5 7.10 6.31 6.33
10% 101 7.59 6.85 6.88
50% 467 9.48 9.26 9.11
forwarding chain, 8 tail calls 0% 1.54 1.34 1.52 1.59
50% 188 4.74 4.31 4.28
void through 4 calls 0% 3.00 3.09 3.05 3.06
1% 9.82 3.33 3.32 3.31
16-byte struct (two registers) 0% 3.01 3.24 3.06 3.06
32-byte struct in memory 0% 5.40 6.06 5.75 5.44
1% 11.3 6.03 5.60 5.43
50% 314 6.26 5.93 5.92
pointer, single call 0% 0.75 0.95 0.75 0.75
50% 188 3.66 3.29 3.08

We notice several things. First, the pointer strategy often has noticeable overhead due to additional memory instructions, and is inferior to the other two. register and carry are nearly identical, which, given the fragility of the carry approach, means that register seems to be the preferred choice. Compared to classical C++ exceptions, performance of register is within 1-2% on the happy path, where no exception occurs, and it is dramatically faster, up to orders of magnitude, if exceptions indeed occur.

A more realistic workload

These numbers over-inflate the differences because the functions do so little work. As a more plausible workload, we use RapidJSON and benchmark the performance on the nativejson-benchmark, optionally corrupting some inputs to trigger exceptions. RapidJSON can be configured to report failures by error codes instead of exceptions; we call that strategy codes here.

The full results are here; we show a selection below. What the numbers basically show is 1) deterministic exceptions with the register strategy have the same performance and the same code sizes as explicit error codes, 2) performance differences from traditional C++ exceptions are largely within the noise even on the happy path, and 3) in the case of errors they are vastly superior to traditional C++ exceptions.

Happy path: SAX parsing

MB/s, higher is better, best of 5 runs; in parentheses: change relative to codes.

codes dynamic pointer register carry
canada.json 1635.8 1606.6 (-1.8%) 1665.1 (+1.8%) 1606.1 (-1.8%) 1603.3 (-2.0%)
citm_catalog.json 2212.7 2216.0 (+0.1%) 2356.3 (+6.5%) 2206.3 (-0.3%) 2341.5 (+5.8%)
twitter.json 1365.2 1376.5 (+0.8%) 1425.4 (+4.4%) 1377.5 (+0.9%) 1472.0 (+7.8%)

Happy path: retired instructions per parse

Instructions (perf stat), lower is better; in parentheses: change relative to codes.

codes dynamic pointer register carry
sax canada.json 49555378 49903680 (+0.7%) 50294221 (+1.5%) 50850829 (+2.6%) 50738702 (+2.4%)
sax citm_catalog.json 20552806 20212949 (-1.7%) 20761272 (+1.0%) 20802928 (+1.2%) 20730816 (+0.9%)
sax twitter.json 11758968 11547972 (-1.8%) 11799096 (+0.3%) 11850736 (+0.8%) 11813765 (+0.5%)

Errors: small documents, a fraction of them corrupted

ns per document, lower is better, best of 5 runs; in parentheses: change relative to codes.

codes dynamic pointer register carry
0% 1726.1 1692.2 (-2.0%) 1729.3 (+0.2%) 1720.5 (-0.3%) 1721.0 (-0.3%)
1% 1707.3 1812.3 (+6.2%) 1703.3 (-0.2%) 1707.7 (+0.0%) 1713.9 (+0.4%)
10% 1600.8 1848.9 (+15.5%) 1597.3 (-0.2%) 1598.3 (-0.2%) 1614.3 (+0.8%)
50% 1256.5 1895.7 (+50.9%) 1263.6 (+0.6%) 1264.2 (+0.6%) 1275.3 (+1.5%)
100% 868.6 1934.7 (+122.7%) 871.8 (+0.4%) 869.3 (+0.1%) 882.1 (+1.6%)

Size of the parser

Bytes of all GenericReader functions, GenericDocument::ParseStream and parseSAX (into which parts of the parser are inlined); in parentheses: change relative to codes.

codes dynamic pointer register carry
code 17191 18162 (+5.6%) 17540 (+2.0%) 17204 (+0.1%) 17289 (+0.6%)
unwind info 1292 1500 (+16.1%) 1340 (+3.7%) 1268 (-1.9%) 1248 (-3.4%)
exception tables 0 388 0 0 0
total 18483 20050 (+8.5%) 18880 (+2.1%) 18472 (-0.1%) 18537 (+0.3%)

Conclusion

Given that “exceptions” might not always be that exceptional (e.g., when parsing large amounts of user input), deterministic exceptions indeed seem to be a very good idea, and P0709 indeed should be adopted by the C++ standard. Not that this is likely to happen, given the lack of progress on this front, but now we at least have numbers to talk about.

Signed, Unsigned, Misaligned: Lessons from Assembly to SQL

Remember how you learned counting before school?

One, two, buckle my shoe, Three, four, knock at the door, Five, six, pick up sticks, Seven, eight, lay them straight, Nine, ten, a big fat hen.

There were no negative numbers, no decimals, no fractions, and certainly no floating point numbers. Of course, one of the reasons to start with natural integers is that it’s simple and maps to your fingers. But like your fingers, most things in the real world are described by positive numbers. The number of kids in the class, your age, the weight of your bag, and the amount of money you have (kids normally don’t have debt).1

Later in life, you learn about negative numbers and fractions. The difference between two values can be negative, years in history are before Christ, and of course your account balance. So you need lots of different number types to map all the data you gather. But for most things in life, unsigned integers are the right choice. Whether it’s how much inventory is in stock, the number of rows in a table, the IP port number, the ID of a row in a database, an array index, or loop iterations: pretty much everything in real life and a lot in computing is a positive number. This holds true especially in SQL where we have NULL-value semantics, so we don’t need -1 as a special case for unknown.

This article gives an overview of how we at CedarDB handle type information and make sure that your SQL query is both fast and correct. We cover why signedness matters, from the machine instructions your CPU runs up to the columns you declare, and why a Parquet file full of unsigned integers is what finally made us expose them in SQL.

Being a compiling system, we need several type systems that interoperate to make sure sign information does not get lost on the way from SQL user input through our C++ implementation down to the generated machine code. We walk down through those layers in a follow-up post.

Everything Is a Number

Integer types are the foundation of every programming language. Whether you are a systems programmer, embedded developer, or web developer, you always need them. They come in different flavors: signed for differences and offsets, unsigned for identifiers and addresses. But in the end, it all boils down to ones and zeros we interpret differently depending on what we need. uint32_t, int, and float are all 4 bytes in size, as is the emoji 😀 in UTF-8.

For example, the emoji 😀 is stored as the 4 bytes 0xF0 0x9F 0x98 0x80 when we encode it as UTF-8.2 Read as one 32-bit value, that is 0xF09F9880. Feed those exact same bytes to different types and you get:3

Type Value
uint32_t 4036991104
int32_t -257976192
float ≈ -3.95e29
char[4] (UTF-8) 😀

As you can see, the same bits can mean wildly different things.

Isn’t Number Enough of a Type?

In assembly, the bedrock of programming, we notice there are no types. If you have never looked at assembly before, think of registers as named buckets of bits. There are just registers and they can store everything. The only differentiation is the size, whether it’s 8 bits, 8 bytes, or a larger vector register (x86 register overview).4

The same storage can be addressed at four different widths.

But is the fact that assembly has no types even right? How can the computer differentiate between the two comparisons if there are no types?

bool biggerSigned(const int8_t* s, int limit) {
 return *s > limit;
}

bool biggerUnsigned(const uint8_t* u, unsigned limit) {
 return *u > limit;
}

When looking at the assembly, we see that registers don’t have types but assembly does. Depending on the signedness, we use different assembly instructions. In this case, either set if less setl for the signed case, or set if below setb for the unsigned case. On x86 less and greater means signed, while below and above indicate unsigned comparisons.

; *s > limit (signed)
movsx eax, byte ptr [rdi] ; sign-extend s into register
cmp esi, eax
setl al ; set al=1 if less (signed)

; *u > limit (unsigned)
movzx eax, byte ptr [rdi] ; zero-extend u into register
cmp esi, eax
setb al ; set al=1 if below (unsigned)

The result depends on instruction and MSB of the contained value. Nothing in the register says whether `AL` holds 200 or -56.

Notice that the load instruction differs too: movsx sign-extends the signed value, movzx zero-extends the unsigned one. The same holds, e.g., for division instructions or moves with either zero or sign extension. So the type is implicitly given by choosing the right instruction. When reading assembly, we can still infer the data type in most places, but that’s quite tedious.

Encoding the type information in the operations means that whoever or whatever writes the assembly needs to keep track of the types. While this is standard procedure for compilers, it’s quite demanding for programmers, which is one of the reasons why most people don’t program in assembly any more. Tracking types by hand opens endless room for errors. Unless, of course, you have always been curious what sqrt(😀) is.5

High-Level Programming Languages

Now let’s go to the other extreme: how do high-level languages handle number types? At the bottom of the ladder, JavaScript only has support for doubles and not even integers.6 PHP at least stores integers and floats differently. Neither differentiates between signed and unsigned, a clear sign of how far removed from the hardware these languages are.

Java, R, and SQL are a step up: they distinguish between integers and floats, so you won’t accidentally mix them and get flaky results due to numeric instabilities. But signed and unsigned remain the same type here too.

Java noticed the gap about 10 years ago and added the Integer.divideUnsigned function, and for PostgreSQL you can install an extension for custom unsigned types. Some control, but not the full picture.

Systems Programming

Full control over the datatypes, however, is crucial for systems programming. If you disagree, think of the first time you had to debug code like this:

unsigned count = 10;
while (count >= 0) {
 std::cout << count << '\n';
 count--;
}

If you don’t immediately see the problem, think of when count >= 0 holds true and why the answer is always true! Static type checking can do a lot of good here. It’ll warn you at compile time that your code won’t terminate.

The mirror image happens with signed values. A classic trap is a count that can go negative meeting an unsigned loop bound:

int n = count_results(); // -1 on error
for (size_t i = 0; i < n; ++i) {
 process(buffer[i]);
}

Signed intuition says that if n is -1, then 0 < -1 is false, so the loop body never runs. But n is compared against size_t i, so it is implicitly converted to SIZE_MAX, which is the largest unsigned value. Now 0 < SIZE_MAX is true, and the loop you expected to skip on error instead runs far past the end of the buffer — reading out of bounds almost immediately, and, absent a crash, looping for about 200 years on a 3 GHz machine.7

Both bugs have the same root cause: the programmer’s mental model uses signed semantics, but the machine applies unsigned arithmetic. This is also precisely why SQL’s NULL is a better tool than -1 for representing missing values.

Nullability vs Unsignedness

The intent of -1 in count_results() in the previous example was to mark an invalid value, since a count cannot be negative. The only reason it’s declared an int was the error case.

Comparing nullability with unsignedness seems odd at first glance. But they have two things in common. First, strictly speaking, neither is a first class citizen of the SQL standard. Nullability is expressed as a constraint, and unsigned simply is not defined. And second, you often use them to report invalid results, as shown above.

-1 to Mark Invalid Numbers

Way before C++ had optionals, SQL had nullability. This prevents programmers from the unfortunate habit of using -1 or even worse 99998 or January 1, 17539 for invalid values.

Using negative numbers just for error codes wastes half of the number range, which is by far not the worst thing here. Imagine your unsigned data neatly stored into an int column with some -1 outliers. You cannot take an average, or sum. You always need to handle your special values.

SQL nullability gives you a better tool: instead of hijacking a valid number to mean “nothing”, you declare that the value simply does not exist. No magic constants, no corrupted aggregates, no silent bugs.

Left: a tree with no leaves, which is still a tree. Right: no tree. The first is `0`, the second is `NULL`.

The number zero is not the same as nothing, like a tree without leaves is not the same as no tree.

Constraints in SQL

You can express unsigned numbers in plain PostgreSQL as:

CREATE TABLE inventory (
 product_id INTEGER,
 quantity INTEGER CHECK (quantity >= 0)
);

This gives us few advantages but big disadvantages. On the upside, we now have positive numbers in the column. But the price we pay is that we waste half of the representable range and have to evaluate the constraint for each compute step. You add two numbers, check the constraint, insert one, check the constraint. And you cannot even store larger numbers here. So it’s not worth it.

Furthermore it gives you a false sense of safety. It only guarantees that the values stored in the table are positive, not that they stay positive during a query. Subtract two quantities and you are negative again, so nothing downstream can rely on it either.

So we need both: values that may be absent (NULL), and values that are never negative (unsigned). SQL gives us only the first one.

Unsigned Types in CedarDB

The SQL standard does not define unsigned datatypes. To be fair to the standard, it also does not define how to page through a result set, so unsigned numbers are in good company here. The common workaround is constraints, but like NOT NULL, it makes more sense to implement the type properly. This gives it a wider range and avoids the overhead of constraint evaluation on every operation.

At CedarDB we believe you should store data in its most natural representation and avoid unnecessary conversions. So we expose the unsigned types we already used internally directly to the user. We used this as an opportunity to refactor our type system, making it easier to add new types and functions going forward.

The driving motivation for integration was Parquet. Parquet’s integer type carries a signedness flag right next to its bit width, and files in the wild use it for identifiers, offsets, counters, and byte sizes. If you are parsing Parquet data types, we can use the native unsigned type directly, otherwise UInt64 columns would have to be widened to 128-bit numerics just to avoid overflow. That costs memory and speed on every single value, for data that would perfectly fit into 64 bit wide registers.

Unlike in C and C++ which inherited it, unsigned does not lead to silent wrap-arounds. We check for overflow on signed and unsigned arithmetic alike, so subtracting 5 from a quantity of 3 raises an error instead of handing you 4294967294. How we do that without giving up performance is a story of its own, in our posts on overflow handling and vectorized overflow checking.

Unsigned types cast implicitly to any type that can always hold them, so mixing them with signed integers just works. Explicit casts let you convert back to unsigned whenever needed. So if you choose not to use unsigned numbers, you will never know they exist.

Using Unsigned Numbers

Everything sounds nice and easy, and using them is the same. Just declare the column with the type you want:

CREATE TABLE inventory (
 product_id uint4,
 quantity uint4
);

If you already worked with Parquet files following our examples, chances are high you’re already working with unsigned numbers and didn’t notice.

You can find more examples in the docs.

Making unsigned a first-class citizen throughout the whole code-generating system takes more than just adding it to the catalog. In the next blog post we go from the ground up through our type system. We will look where sign information is tracked and where it deliberately is not, why PostgreSQL’s oid is an unsigned in disguise, and what the System V ABI forgot to specify.

Want to see it in action? Load a Parquet file into CedarDB and the right unsigned types come along for free.


  1. Although money is a decimal, it does not have to be. Reporting your net worth in cents is both technically correct and psychologically effective. ↩︎

  2. How a character turns into bytes depends on the encoding. The same emoji is 0x00 0x01 0xF6 0x00 in UTF-32BE, and Python’s '😀'.encode('utf-32') even prepends a byte order mark. Some emoji are not a single codepoint at all, but a sequence glued together with zero-width joiners. This article covers the rest. ↩︎

  3. Reading four bytes as one number is itself an interpretation. We use the big-endian reading here, the order we wrote the bytes in. On a little-endian machine such as x86, loading the same bytes into a uint32_t gives you 2157486064 instead. ↩︎

  4. Yes, there are different registers for integers and floats. You can also store integers in vector registers for SIMD processing, or floats in regular registers for bit tricks. ↩︎

  5. It’s NaN. sqrt takes a float, and as a float 😀 is negative. ↩︎

  6. JavaScript engines do support integers internally, but only as a storage optimization for typed arrays, not as an exposed type. ↩︎

  7. Yes, that’s an em-dash I put there. I liked to use them before they became a symbol of LLM generated content and it’s time, we are taking them back. For now I just put one into the post to not overdo it. ↩︎

  8. In METAR, the standard format for aviation weather reports, horizontal visibility is given in meters and caps out at four digits, so 9999 means “10 km or more” rather than “9,999 meters”. See the METAR explanation in the IVAO documentation. ↩︎

  9. Microsoft’s Date Data Type documentation for Dynamics NAV. The date range starts at January 1, 1753, which is the lower bound of SQL Server’s datetime, which in turn dates back to Britain switching to the Gregorian calendar in 1752. ↩︎

September 28, 2026

Resolving query plan regressions after a MySQL engine upgrade

After a major or minor version upgrade on Amazon Aurora MySQL or Amazon RDS for MySQL, some queries regress because the optimizer's cost models, defaults, and execution strategies change. This post walks through a diagnostic workflow that traces each regression to the specific version change behind it and applies the right fix.

PgQ and PgQue: Workflow Engines you might need in PostgreSQL

In many real production databases, we often see some tables receiving high-frequency updates (millions per day) and autovacuum running repeatedly (hundreds of times per day). Additionally, those tables can sometimes become heavily bloated. The end effect is poor performance, poor concurrency, and an unmanageable database. In case we are using methods pg_gather for diagnosis, such … Continued

The post PgQ and PgQue: Workflow Engines you might need in PostgreSQL appeared first on Percona.

When pt-online-schema-change “where” Meets Galera: Understanding Chunk Auto-Resize and Flow Control

Percona Toolkit’s pt-online-schema-change (pt-osc) has long been the preferred solution for performing online schema changes with minimal downtime. Its chunk-based copy algorithm is designed to adapt dynamically to the workload, making it suitable for very large tables in production environments. However, under specific conditions, one of its optimization mechanisms can become counterproductive. During a customer … Continued

The post When pt-online-schema-change “where” Meets Galera: Understanding Chunk Auto-Resize and Flow Control appeared first on Percona.

Handling hot shards

What makes a good sharding strategy today might not work so well tomorrow. What happens when you have one tenant hitting your database orders of magnitude more than any other? Or product managers knocking down your door with new requirements? If you're already on Neki, fortunately these are issues we can solve.

September 25, 2026

How to stream PostgreSQL changes to Amazon S3 with AWS Fargate

In this post, we show you how to build a fully managed, event-driven change data capture (CDC) pipeline. It streams row-level changes from Amazon RDS for PostgreSQL or Amazon Aurora PostgreSQL to Amazon S3 in near real time. You deploy the entire pipeline with a single AWS CloudFormation template, and it can run in private subnets with no internet gateway without exposing resources to the public internet.