YSQL catalog cache preloading can reduce the catalog reads performed when a new PostgreSQL backend processes its first queries.
However, on older YugabyteDB releases, you may encounter a confusing situation: you explicitly add pg_operator to the catalog preload list, but the first query on every new connection still performs a catalog read against pg_operator.
Is the preload flag being ignored?
Not exactly.
The rows from pg_operator may already be preloaded, but the query is using a different type of PostgreSQL catalog cache lookup… one that older YugabyteDB versions cannot satisfy from the individual preloaded rows.
This tip reproduces the behavior on YugabyteDB 2024.2.7.1 and explains the difference between individual catalog tuple entries and catalog cache list entries.
Version note: This demonstration uses YugabyteDB 2024.2.7.1 for both the ysqlsh client and the YugabyteDB server. The enhancement discussed in this tip was added in YugabyteDB 2025.2.4.0 and is also included in YugabyteDB 2026.1.
The Test Environment
This demonstration uses yugabyted to create and manage a local single-node YugabyteDB cluster. Using yugabyted keeps the setup simple and makes it easy to restart the cluster with different catalog preload flags during the test.
Check the YugabyteDB server version:
./yugabyted version
Example output:
----------------------------------------------------------------------
| Version |
----------------------------------------------------------------------
| Version : 2024.2.7.1-b1 |
| Build Time : 30 Dec 2025 19:05:31 UTC |
| Build Hash : 13a0684aa7a7a0cb1d4fb9f1e8e67a67acc7f912 |
----------------------------------------------------------------------
For a clean test, destroy and restart the local cluster:
CREATE TABLE test (
c1 INT,
c2 INT,
PRIMARY KEY (c1, c2)
)
PARTITION BY LIST (c2);
Create two partitions:
CREATE TABLE test1
PARTITION OF test
FOR VALUES IN (1);
CREATE TABLE test2
PARTITION OF test
FOR VALUES IN (2);
Create an index on each partition:
CREATE INDEX test1_idx
ON test1 (c2)
INCLUDE (c1)
TABLESPACE test1_tablespace;
CREATE INDEX test2_idx
ON test2 (c2)
INCLUDE (c1)
TABLESPACE test2_tablespace;
DEFAULT=""
y 2>&1 <<'SQL' \
| grep "Target rel:" \
| awk '{print $3}' \
| sort -u \
| awk -v re="$DEFAULT" 're=="" || $0 !~ re' \
| paste -sd, -
SET yb_debug_log_catcache_events = 1;
SET yb_debug_report_error_stacktrace = 1;
SET client_min_messages = LOG;
EXPLAIN (ANALYZE, DIST)
SELECT *
FROM test
WHERE c2 = 1;
SQL
The result is surprising:
pg_operator
Why?
Look at the Full Cache Miss
Run the query without filtering the debug output:
y \
-c "SET yb_debug_log_catcache_events = 1;" \
-c "SET yb_debug_report_error_stacktrace = 1;" \
-c "SET client_min_messages = LOG;" \
-c "EXPLAIN (ANALYZE, DIST) SELECT * FROM test WHERE c2 = 1;"
The relevant portion of the output is:
LOG: Catalog cache list miss on cache with id: 37:
Target rel: pg_operator (oid: 2617), index oid: 2689
Search keys: =, 23, 23
@ SearchCatCacheList
@ OpernameGetOprid
@ oper
@ make_op
@ transformSelectStmt
The execution plan also reports one catalog read:
Catalog Read Requests: 1
Catalog Read Execution Time: 2.602 ms
The most important words in the log are:
Catalog cache list miss
This is not an ordinary single-row catalog cache miss.
Tuple Cache Entries Versus List Cache Entries
PostgreSQL’s catalog cache can store different forms of cached information.
Cache object
Internal name
Purpose
Individual catalog tuple
CatCTup
Stores one specific catalog row identified by a complete cache key.
Catalog cache list
CatCList
Stores a complete set of rows matching a partial or list-oriented cache key.
Individual tuple entries
A normal catalog cache lookup searches for one specific catalog row using a complete cache key.
Conceptually:
Find the pg_operator row whose OID is 96.
The result is stored as an individual catalog cache tuple, commonly called a CatCTup.
The ysql_catalog_preload_additional_table_list setting preloads these individual tuple entries for the selected catalog tables.
Catalog list entries
Some PostgreSQL operations need a set of matching rows rather than one exact row.
For example, while resolving:
c2 = 1
the parser must identify the appropriate = operator for the supplied operand types.
That lookup follows this path:
OpernameGetOprid
-> SearchCatCacheList
The resulting collection is stored as a catalog cache list, commonly called a CatCList.
Conceptually, the lookup asks:
Find the candidate "=" operators matching these operand types.
That list is a separate cache object from the individual preloaded pg_operator rows.
What Do the Search Keys Mean?
The debug output shows:
Search keys: =, 23, 23
These keys represent:
Search key
Meaning
=
The operator name being resolved.
23
The OID of the PostgreSQL int4 data type for the left operand.
23
The OID of the PostgreSQL int4 data type for the right operand.
The parser is effectively trying to resolve:
integer = integer
Why Preloading pg_operator Was Not Enough
On YugabyteDB 2024.2.7.1, preloading pg_operator loads its individual catalog tuples into the backend cache.
However, it does not construct every possible CatCList that PostgreSQL may later request.
When SearchCatCacheList asks for the operator list identified by:
=, 23, 23
the backend does not yet have an entry proving that the requested list is complete.
Even though the individual pg_operator rows are already present, the older code path still performs a catalog read to construct the list.
The important distinction: Preloading the rows from a catalog table is not the same as prebuilding every catalog cache list that can be derived from those rows.
Why Did pg_amop Disappear?
Certain frequently used list-cache paths already had specialized optimizations that prepopulated common list entries.
These included some lookups involving catalogs such as:
pg_proc
pg_amop
That is why adding pg_amop to the preload list can eliminate its catalog read while pg_operator remains.
Creating equivalent special cases for every possible pg_operator lookup would be difficult. Operators can be searched using different combinations of:
The enhancement introduced a more general solution.
When a catalog cache is known to be fully loaded, YugabyteDB can satisfy a list-cache miss by walking the individual tuples already present in the backend’s local cache.
Instead of doing this:
SearchCatCacheList
-> Read pg_operator from the YB-Master
newer versions can do this:
SearchCatCacheList
-> Scan the already-preloaded pg_operator tuples
-> Build the CatCList locally
The implementation checks that:
The catalog cache is fully loaded
AND
The number of cached tuples is below the configured limit
Version Availability
Release
Behavior
Notes
2024.2.7.1
The list lookup still performs a catalog read.
Version used in this reproduction.
2025.2.4.0+
Eligible cache lists can be built from preloaded tuples.
Includes the backport of issue #30868.
2026.1.0.0+
Eligible cache lists can be built from preloaded tuples.
The enhancement is included in the 2026.1 release.
Important: The enhancement is not available in every YugabyteDB 2025.2 release. It was added to the 2025.2 branch in YugabyteDB 2025.2.4.0.
Does pg_operator Cache at All on Older Releases?
Yes.
The list lookup is cached after it is built in that PostgreSQL backend.
The key detail is that every execution of the shell test creates a new ysqlsh connection and therefore a new backend process.
On YugabyteDB 2024.2.7.1, the sequence is:
The catalog read is not necessarily performed for every query. It is generally performed once for that list lookup in each new backend, unless the cache entry is later invalidated.
Test Two Queries in the Same Backend
To demonstrate the difference, run the query twice through the same ysqlsh connection:
y 2>&1 <<'SQL'
\echo 'First execution'
EXPLAIN (ANALYZE, DIST)
SELECT *
FROM test
WHERE c2 = 1;
\echo 'Second execution in the same backend'
EXPLAIN (ANALYZE, DIST)
SELECT *
FROM test
WHERE c2 = 1;
SQL
Example output:
First execution
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Append (cost=0.00..5.11 rows=10 width=8) (actual time=0.604..0.604 rows=0 loops=1)
-> Index Only Scan using test2_idx on test1 (cost=0.00..5.06 rows=10 width=8) (actual time=0.603..0.603 rows=0 loops=1)
Index Cond: (c2 = 1)
Heap Fetches: 0
Storage Index Read Requests: 1
Storage Index Read Execution Time: 0.250 ms
Planning Time: 0.501 ms
Execution Time: 0.714 ms
Storage Read Requests: 1
Storage Read Execution Time: 0.250 ms
Storage Rows Scanned: 0
Catalog Read Requests: 1
Catalog Read Execution Time: 0.867 ms
Catalog Write Requests: 0
Storage Write Requests: 0
Storage Flush Requests: 0
Storage Execution Time: 1.117 ms
Peak Memory Usage: 16 kB
(18 rows)
Second execution in the same backend
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Append (cost=0.00..5.11 rows=10 width=8) (actual time=0.399..0.399 rows=0 loops=1)
-> Index Only Scan using test2_idx on test1 (cost=0.00..5.06 rows=10 width=8) (actual time=0.399..0.399 rows=0 loops=1)
Index Cond: (c2 = 1)
Heap Fetches: 0
Storage Index Read Requests: 1
Storage Index Read Execution Time: 0.354 ms
Planning Time: 0.066 ms
Execution Time: 0.419 ms
Storage Read Requests: 1
Storage Read Execution Time: 0.354 ms
Storage Rows Scanned: 0
Catalog Read Requests: 0
Catalog Write Requests: 0
Storage Write Requests: 0
Storage Flush Requests: 0
Storage Execution Time: 0.354 ms
Peak Memory Usage: 8 kB
(17 rows)
Note that in he first execution, we see:
Catalog Read Requests: 1
While in the second execution, we observe:
Catalog Read Requests: 0
The second execution in the same backend can reuse the resulting list, provided the entry has not been invalidated.
When Is This Most Noticeable?
The extra first-query catalog lookup is most noticeable in environments with:
● High connection churn
● Short-lived database sessions
● Applications without connection pooling
● Serverless or job-based workloads that frequently reconnect
● Multi-region deployments where catalog RPC latency is higher
● Large numbers of new backends starting at the same time
Connection pooling reduces this cost because applications reuse warmed PostgreSQL backend processes instead of continuously creating new ones.
What Should You Do?
On YugabyteDB 2024.2.7.1
Adding pg_operator to the preload list still preloads the table’s individual tuple entries, but it cannot eliminate the first SearchCatCacheList read in every new backend.
The practical options are:
1. Use connection pooling so warmed PostgreSQL backends are reused.
2. Accept the one-time list-cache lookup on each new backend.
3. Upgrade to a YugabyteDB release that contains the issue #30868 enhancement.
When the pg_operator cache is fully loaded and remains below yb_catcache_list_from_preloaded_limit, YugabyteDB can build the requested list locally without an additional catalog read.
Final Takeaway
Preloading a PostgreSQL catalog table does not necessarily mean that every possible catalog cache object derived from that table has already been created.
On YugabyteDB 2024.2.7.1:
pg_operator tuples are preloaded
but:
pg_operator CatCList entries are not
The predicate:
WHERE c2 = 1
uses SearchCatCacheList while resolving the = operator.
Because older YugabyteDB versions cannot build that list from the individual preloaded tuples, the first lookup in each new backend still performs a catalog read.
YugabyteDB issue #30868 closes that gap by allowing eligible catalog cache lists to be constructed from catalog tuples that are already preloaded in memory.
So pg_operator was not refusing to cache… it was waiting for a different kind of cache entry.
Lists the catalog cache list enhancement in YugabyteDB 2025.2.4.0.
Have Fun!
It’s one of my favorite time of year, a day afer Samsung’s Galaxy Unpacked event, when I can begin my annual Samsung upgrade cycle! Today, I kicked things off with the Galaxy Watch Ultra 2. ⌚📱