This post shows you how to set up centralized cross-account and cross-Region monitoring for Amazon Relational Database Service (Amazon RDS) and Amazon Aurora databases using Amazon CloudWatch Database Insights. Whether your databases are spread across two AWS accounts or ten, and across one Region or several, this walkthrough gives you a single monitoring account with visibility across your entire database fleet.
PostgreSQL scans operate at the page level: the buffer manager fetches one 8 KB page (BLCKSZ) at a time, issuing one read per block. The operating system may merge some of these requests through readahead, but PostgreSQL still generates many small I/O operations, leading to a high number of system calls on large scans. This is inefficient for streaming access patterns.
Operations like Seq Scan and Bitmap Heap Scan know which blocks they need ahead of time and can read them independently, unlike Index Scans where each next block depends on the previous one.
PostgreSQL 19 changes this with the new read stream layer. Instead of issuing one read per page, it groups adjacent blocks and combines them into larger I/O requests, up to io_combine_limit. The logical unit remains the 8 KB page, but physical I/O is no longer page-by-page. This reduces system call overhead and makes better use of modern storage.
IO combining and prefetch
PostgreSQL 19 (currently in beta) introduces Asynchronous I/O (AIO), enabling non-blocking reads for operations involving multiple blocks. Instead of waiting for each read to finish before issuing the next, PostgreSQL can pipeline I/O requests using methods such as worker threads, io_uring, or a synchronous fallback. The AIO read pathway creates a look-ahead stream of block requests, grouping nearby blocks into larger I/O operations. This process attempts to coalesce adjacent blocks into a single request, subject to the io_combine_limit.
Prefetch or read-ahead still involves requesting blocks before they are needed, but with AIO, this is now integrated with asynchronous submission and batched reads, reducing reliance on implicit operating system readahead by issuing explicit asynchronous and batched reads. These improvements can be seen with EXPLAIN (ANALYZE, IO), which provides detailed I/O statistics.
PostgreSQL 19 (beta)
If you want to test the beta of PostgreSQL 19, here is how to start a container that exposes port 5432:
docker run -d--name pg19 \-d-p 5432:5432 \-ePOSTGRES_PASSWORD=xxx \
postgres:19beta1 postgres
If you read this later, use the release candidate or the final release.
AIO configuration
I connect with PGUSER=postgres PGPASSWORD=xxx PGHOST=localhost psql and check the IO configuration:
You may have heard about io_uring, a Linux I/O interface that provides true asynchronous I/O without requiring Direct I/O, unlike the legacy AIO interface. It’s not available everywhere, and I can’t use it from Docker here, but the worker method still enables concurrent reads and some I/O combining. It's the default in PG19.
With this configuration, two workers can combine up to 128kB of IO reads, which is 16 blocks, since the block size is 8KB.
Seq Scan on small rows table (inline)
I create a "smalldocs" table and load it with 1,024,000 rows, each with a random 1KB text in the "data" column:
This plan shows a parallel sequential scan efficiently scanning the 1GB table using PostgreSQL’s AIO read path. Each of the 3 parallel processes (leader + 2 workers) scans part of the table, and the read stream keeps a large look‑ahead (about 35 blocks on average), so data is requested well before it is needed. Those blocks are grouped into larger I/O requests (around 16 blocks per read, about 128KB), which reduces overhead.
Because reads are submitted in advance, almost all I/O completes asynchronously: only 19 waits out of more than 8 thousands requests. At any time, a few I/Os are in flight (around 3), keeping the storage busy.
In short, this is an ideal case for AIO: sequential access enables deep prefetching, combined reads, and very few stalls, so the scan runs close to I/O throughput limits rather than being blocked on individual reads.
Those reads can be traced with strace, they are pread64 calls from the postgres: io worker processes:
Here, strace shows PostgreSQL I/O workers issuing pread64 calls on the table file, with most reads at 131072 bytes (128KB), corresponding to 16 PostgreSQL pages. This confirms that sequential scan uses I/O combining, grouping multiple 8KB blocks into larger reads. Multiple pread64 calls are marked as and later resumed, showing that reads are in flight concurrently. This matches the AIO model: requests are submitted ahead of time, and completion is picked up later, rather than waiting for each read. Occasional smaller reads (8KB, 16KB, 32KB) appear at boundaries or when combining is not possible, but the dominant pattern is large, aligned reads. Overall, the trace confirms what EXPLAIN reports:
reads are combined into larger I/O (about 128KB)
multiple I/Os are issued in parallel (pipelining)
backend rarely waits, as I/O completes asynchronously
This is a direct observation of PostgreSQL AIO read streams: look‑ahead + I/O combining + concurrent execution, achieving high throughput on sequential scans.
Seq Scan on oversized rows table (TOASTed)
I create another similar table, "largedocs", and load it with fewer and larger rows. My goal is to show what happens when TOAST kicks in with large extended data types. I load 1000 rows, each with a random 1MB text in the "data" column:
Here, the sequential scan looks trivial, but most of the work is not in the main table. Only 1000 rows are scanned, and they are small (just TOAST pointers), so the Seq Scan itself does almost no I/O: only 8 blocks are read, with no parallelism and almost no prefetching (avg=1.88).
However, execution time is much higher (2.7s) because each row requires fetching the TOASTed value to compute length(data). The access pattern is no longer sequential. Instead of scanning a contiguous stream of blocks, PostgreSQL performs a separate lookup into the TOAST table for each row.
Those reads are effectively random, so there is no opportunity for read‑ahead or I/O combining. The AIO read stream cannot build a pipeline, and PostgreSQL falls back to small reads driven by the executor, one TOAST value at a time.
I've left my strace running, and it shows only the four reads going to the IO workers (I used pgrep -f "postgres: io worker"), between 1 and 4 blocks (8kb and 32kb):
The strace confirms this behavior. The I/O workers only handle a few reads on the main table, between 1 and 4 blocks (8KB to 32KB), which explains why almost nothing shows up there.
I can check that the relation base/5/16397 is the table "largedocs":
When tracing all PostgreSQL backends (using pgrep -f "postgres: " processes), the actual workload appears: a large number of 8KB pread64 calls on the TOAST table (base/5/16402). These reads are small, scattered, and not combined:
This is the opposite of the previous example. With small rows, the sequential scan becomes a true streaming workload, where AIO can prefetch and combine I/O efficiently. With large TOASTed values, the same sequential scan degenerates into many random lookups, in which prefetching and I/O combining are ineffective, and AIO offers little benefit.
eBPF (block layer)
At the syscall level, we saw how PostgreSQL issues fewer, larger reads, which reduce context switches. To see what actually reaches the storage device, we need to look at a lower layer.
To observe what actually reaches the storage device, I traced block I/O requests with eBPF. Because this runs at the block layer, it doesn’t show PostgreSQL logical reads, but it does show I/O requests after the filesystem, page cache, readahead, and request merging. First, I clear the cache to make sure reads hit the device, then I trace block requests and aggregate their sizes:
On the sequential scan of smalldocs, the distribution shows a wide range of request sizes. Large requests like 256KB, 512KB, or even 1MB appear frequently:
On largedocs, with TOASTed values that are read by PostgreSQL with 8kB reads, smaller sizes are more visible, but surprisingly large requests still appear at block level:
This is because we are no longer looking at what PostgreSQL requests, but at what reaches the storage after the OS stack has optimized it, and because my TOAST chunks, inserted in bulk, are contiguous. The filesystem performs read-ahead, and the kernel can merge adjacent requests, producing larger I/O operations than those issued by PostgreSQL.
Importantly, this still uses buffered I/O through the filesystem cache. With Direct I/O, such merging would be much more limited, and request sizes would more closely reflect what the database issues.
This explains why both workloads can show similar block‑level patterns, when the blocks read are contiguous.
Even when the block layer ends up issuing similar I/O sizes after merging, the syscall pattern still matters: fewer large reads mean fewer syscalls and fewer context switches, while many small reads increase CPU overhead.
In short, strace shows what PostgreSQL requests, while eBPF shows what actually reaches the device.
Conclusion
This highlights a simple rule: AIO helps when PostgreSQL can see and exploit a sequential access pattern. With many small rows, a Seq Scan becomes a streaming workload where the read stream can prefetch ahead, combine blocks into larger I/O, and pipeline requests efficiently.
However, With large TOASTed values, the same scan turns into thousands of small, random lookups, where there is no locality to exploit: no effective prefetching, no I/O combining, and almost no benefit from AIO.
To understand what is happening at each layer: EXPLAIN shows intent, strace shows requests, and eBPF shows what actually reaches the device.
In this post, you learn how to design and implement a user authentication service with session management on Amazon Aurora DSQL. You see the full request flow from client to database and back, explore the design considerations specific to Amazon Aurora DSQL, and discover practical lessons from building and testing against a live cluster.
PostgreSQL community images address a real gap in how a Kubernetes database operator earns your trust. Running a database operator on Kubernetes means trusting two things: the code, and the container images the operator pulls. The code is on GitHub, easy to inspect, easy to fork. The container images, the registry that hosts them, and the … Continued
Debugging applications in Kubernetes can be tricky. Containers are designed to be small, immutable, and purpose-built. That is great for production, but not always ideal when something breaks. Many production images are minimal or distroless. They may not include tools that are useful for troubleshooting. In some cases, the application container may already be crashing, … Continued
A few years ago, if there was a discussion on “Should we run databases on Kubernetes?”, there were more people saying no than yes. One of the common answers was, “No. Kubernetes is for stateless workloads. Keep your databases outside.” Thankfully, today the discussion is no longer about whether we should run databases on Kubernetes, … Continued
A new endpoint exposes ClickHouse's lightweight DELETE functionality in Tinybird: pick between a synchronous call that blocks until the delete is done, or an asynchronous one that you poll for partition progress.
You may have read about Hybrid search for Azure HorizonDB. It is presented as combining BM25 full‑text and vector similarity in a single query. But how are they actually combined? The execution plan answers that.
In this post, I use a small synthetic product catalog to ensure the entire demo is reproducible. The text is sufficiently realistic for BM25 queries, and the embeddings are deterministic synthetic vectors, allowing you to run the full script without needing an embedding model. If you have azure_openai.create_embeddings() configured, you can substitute the synthetic embedding function with actual embeddings.
I will test two separate queries, followed by three methods of combining BM25 with vector retrieval:
cascade (BM25 → vector)
cascade (vector → BM25)
hybrid (parallel + fusion)
These are not interchangeable; they represent distinct trade-offs between recall and performance.
Setup
This example uses:
pg_textsearch for BM25 full-text search
pgvector for the vector type and distance operator
pg_diskann for the vector index, when available
The extensions must be listed in azure.extensions to enable CREATE EXTENSION.
In addition, pg_textsearch must be loaded on startup:
I should use azure_openai.create_embeddings(), as I mentioned in the previous blog post. However, for this demo, I opted to create a fake embedding function that maps text to a simple vector(16) based on product keywords. This isn't an embedding model, but it ensures that execution plans are reproducible without relying on a model. It also simplifies the concept of embeddings with a basic, small-dimension LIKE-style semantic vector. The purpose is to demonstrate a search query using both a real text search and a vector-based semantic search.
Using a straightforward CASE that searches for specific words, I create the semantic vector for a text. This method employs vector similarity solely for demonstration, without utilizing a model. You can envision AI models doing the same but with thousands of dimensions driven by large language models (LLMs) rather than keywords.
Table with text and embeddings
I created a product catalog table with structured fields, text, and a single vector column:
I loaded a few hundred thousand rows. The data is synthetic but intentionally patterned: products have categories, materials, styles, and terms that are useful for both BM25 and vector search:
insertintoproducts(product_id,category,brand,price,title,description,embedding)withgeneratedas(selectgasproduct_id,(array['chair','table','sofa','lamp','desk','shelf'])[1+(g%6)]ascategory,(array['Contoso','Fabrikam','Northwind','AdventureWorks','Wingtip','Tailspin'])[1+(g%6)]asbrand,(array['mid-century modern','industrial','scandinavian','classic','minimalist','outdoor'])[1+(g%6)]asstyle,(array['walnut wood','black metal','oak wood','leather','fabric','brushed steel'])[1+((g/7)%6)]asmaterial,(25+(g%500))::numeric(10,2)aspricefromgenerate_series(1,200000)asg)selectproduct_id,category,brand,price,initcap(style||' '||material||' '||category)astitle,concat(style,' ',category,' by ',brand,' with ',material,'. Designed for ',casewhencategoryin('chair','sofa')then'living room seating'whencategoryin('table','desk')then'home office and dining'whencategory='lamp'then'warm interior lighting'else'storage and display'end,'. Product code ',product_id,'.')asdescription,demo_embedding(concat_ws(' ',style,material,category,brand))asembeddingfromgenerated;
I tested some searches on search_text and embedding and started indexing those columns.
Create the indexes (BM25 and DiskANN)
First, I created the BM25 full-text index. HorizonDB’s BM25 full-text search brings BM25 ranking into PostgreSQL without a separate Elasticsearch/OpenSearch Search service. It uses the open-source extension pg_textsearch:
I started with a keyword search, using to_bm25query() to define the BM25 query. Ranking uses BM25 and is performed with the <@> operator. Top-k queries use this operator in ORDER BY ... LIMIT.
BM25 is implemented as an index-backed operator that must be bound to a specific index. This is why prepared statements require explicitly naming the index:
postgres=>preparequery1(text,int)asselectp.product_id,p.title,p.category,p.brand,p.pricefromproductsporderbyp.search_text<@>to_bm25query($1,'products_bm25_idx')limit$2;postgres=>explain(analyze,buffers,verbose,costsoff)executequery1('mid century modern wooden chair',10);QUERYPLAN---------------------------------------------------------------------------------------------------------------------------------------------Limit(actualtime=0.268..0.442rows=10loops=1)Output:product_id,title,category,brand,price,((search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query))Buffers:sharedhit=582->IndexScanusingproducts_bm25_idxonhybrid_demo.productsp(actualtime=0.267..0.440rows=10loops=1)Output:product_id,title,category,brand,price,(search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query)OrderBy:(p.search_text<@>'products_bm25_idx:mid century modern wooden chair'::bm25query)Buffers:sharedhit=582QueryIdentifier:-4837396746836655100Planning:Buffers:sharedhit=1PlanningTime:0.116msExecutionTime:0.460ms(12rows)
The Index Scan returns the Top-10 result ('rows=10') directly in ranking order (Order By).
Lexical retrieval is good for exact words, rare terms, product codes, and anything where the user expects the same token to appear in the document.
Query 2: ANN only
I further explored the semantic aspect by performing similarity search using the cosine distance operator (<=>) for vectors:
Upstream MySQL published an out-of-schedule release this week with two high-severity CVE fixes. We’ve pulled those fixes into our next builds and are skipping the two versions we had already queued: Percona Server for MySQL 8.4.9 and 9.7.0. These fixes arrived through Oracle’s new monthly Critical Security Patch Updates (CSPUs), which Oracle announced begin May … Continued
The term “HTAP” is the holy grail of database systems. It describes what every data engineer would
love: Being able to do all your data Processing, no matter if it’s complex Analytics or
fast-paced Transactional operations in a single Hybrid system.
Many system have tried to enable HTAP but have found that building a truly hybrid system is an
impossible challenge. The keynote talk at Databricks Data + AI Summit 2026 highlighted that it is
impossible to get a query on the analytical warehouse to execute in less than 1 second. As a new
solution that solves this, the Databricks cofounder Reynold Xin announced “Databricks LTAP” and
directly cited our paper on “Morsel-Driven Parallelism” as one of the “latest and coolest academic
papers”:
With their LTAP offering, Databricks is actually able to offer sub-second transactional performance
while maintaining its well-known analytical performance. That’s genuinely impressive! But if you
look under the hood (e.g. by watching Databricks engineers talk about the technology behind LTAP),
you will see that LTAP still uses the same classical separation between operational and analytical
data. By their own description, LTAP does not run on one engine. It keeps a transactional engine and
an analytical engine and unifies them at the storage layer. You can think of this being a really
good, really fast zero-ETL system.
Zero-ETL is not enough, though. As long as you run two engines, you have two sources of truth, and
there is a moment where data crosses from one to the other. Zero-ETL makes that gap small, but even
if the name suggests otherwise, it cannot make it zero. The data still has to move from the system
that wrote it to the system that reads it. When you really want HTAP, you really care about this gap
being actually zero. Take fraud detection: a warehouse can flag suspicious activity only after the
fact, but you want to catch it before the money moves. For zero read lag and a single source of
truth, you have to re-think the entire system around one engine that runs both workloads natively.
It’s almost impossible to change an existing system designed for either transactions or analytics
into a true hybrid system. Database researchers have known this for several decades already. We
weren’t the first to attempt it, and we didn’t coin the term HTAP. Systems like SAP
HANA and HyPer
went after it before us but required your data to fit completely in main memory. Sadly, this didn’t
work out. What made us reconsider that HTAP was back on
the table was that fast SSDs became widely available. So ten years ago, we started
Umbra, a research project with one goal: building a truly HTAP system.
CedarDB is built on that foundation.
Since then, new developments in the database space focused only on analytics, leading to great
analytical systems such as Databricks Lakehouse and Snowflake, and ClickHouse. It turns out the
existing transactional systems, even regular PostgreSQL, scaled to even the most demanding AI
workloads. What’s hard is making sure transactions
and analytics don’t slow each other down when running at the same time.
To make this work well, you need to unify both the execution engine and the storage format without
introducing new bottlenecks. For that, we built a hybrid column-row
format as our data layer. It can support fast writes on hot
data, automatically transforming between hot write-optimized and cold compressed data as needed,
fully transparently as a single copy.
Not only that, we also built the foundations for fast analytical processing on modern hardware. You
can find an overview of key techniques on our technology
page, including morsel-driven parallelism, data-centric code
generation, a cost-based optimizer with full subquery decorrelation, and a buffer manager designed
to fully utilize fast SSDs.
Databricks LTAP is coming soon, CedarDB is in production today! Good to see the industry catching up
to the problem. Come see what the answer looks like when it’s already running.
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:
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.
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=>SELECTazure_ai.generate('Who is Slonik and how does he look like? context:'||version(),'default-chat');generate-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------SlonikisamascotforPostgreSQL,oftendepictedasafriendly,cartoonishelephant.Hetypicallyhasablue-graybody,largefloppyears,andacheerfulexpression.SlonikembodiesthePostgreSQLcommunity'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=>selectazure_ai.is_true(format('%s is the right name for Slonik''s database',unnest)),string_agg(unnest,',')fromunnest(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'])groupby1orderby1desc;agg---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------t|PostgreSQL,Postgres,postgresql,POSTGRES,theelephantf|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(2rows)
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=>SELECTversion();version-----------------------------------------------------------------------------------------------------------------------PostgreSQL17.9(AzureHorizonDB(70f3b593ec7)(release))onx86_64-pc-linux-gnu,compiledbygcc(GCC)13.2.0,64-bit(1row)postgres=>SELECTazure_ai.extract(version(),-- text from which to extractARRAY['PostgreSQL compatibility','Cloud service name']);extract-------------------------------------------------------------------------------{"Cloud service name":"Azure HorizonDB","PostgreSQL compatibility":"17.9"}(1row)
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:
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=>CREATEEXTENSIONvector;CREATEEXTENSIONpostgres=>WITHsettingsAS(SELECTname,short_desc,azure_openai.create_embeddings('default-embedding',row_to_json(pg_settings)::text)::vectorASembeddingFROMpg_settings)SELECTname,short_descFROMsettingsORDERBYembedding<=>azure_openai.create_embeddings('default-embedding','Buffer cache shared memory allocated by PostgreSQL')::vectorLIMIT5;name|short_desc----------------------------+----------------------------------------------------------------------------------------effective_cache_size|Setstheplanner'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'smainsharedmemoryarea(roundeduptothenearestMB).dynamic_shared_memory_type|Selectsthedynamicsharedmemoryimplementationused.(5rows)
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=>WITHsettingsAS(SELECTname,short_desc,row_to_json(pg_settings)::textASdoc,azure_openai.create_embeddings('default-embedding',row_to_json(pg_settings)::text)::vectorASembeddingFROMpg_settings),candidatesas(SELECTname,short_desc,docFROMsettingsORDERBYembedding<=>azure_openai.create_embeddings('default-embedding','Buffer cache shared memory allocated by PostgreSQL')::vectorLIMIT20),rerankedAS(SELECT*FROMazure_ai.rank('Setting the buffer cache shared memory allocated by PostgreSQL',ARRAY(SELECTdocFROMcandidates),-- text to rankARRAY(SELECTnameFROMcandidates),-- id of the item'default-chat'))SELECTr.*,s.short_descFROMsettingssJOINrerankedrONr.id=s.nameORDERBYr.rankLIMIT5;id|rank|score|short_desc----------------------------+------+-------+----------------------------------------------------------------------------------------shared_buffers|1|0.9|Setsthenumberof ... (truncated)
In a previous post, I explored how MongoDB chooses between nested loop, indexed loop, and hash join strategies for $lookup. Here, I examine what occurs when $lookup runs on DocumentDB for PostgreSQL—an open-source extension implementing the MongoDB API on PostgreSQL.
The document model minimizes the need for joins by embedding related data directly within documents. However, when a join is necessary — such as for reference data that updates independently, many-to-many relationships, or dimensional lookups — the flexibility of embedding can complicate join optimization.
The goal isn't just to identify "which database is faster"—it's to understand why their behaviors differ, the trade-offs involved, and the options when join performance matters.
Relational databases tend to perform more joins because normalized schemas require them, but they also optimize joins more effectively thanks to scalar, well-typed columns. In contrast, document databases perform fewer joins thanks to embedding, but when they do, flexible field semantics—such as arrays—restrict the available join algorithms.
I've run all tests in Docker containers with default settings on the same machine. The timings are indicative, not benchmarks — they illustrate the relative cost of different approaches, not absolute performance under production conditions (caching, concurrency, hardware, and tuning would all change the numbers).
An example: fact and reference table
In a document database, you'd typically embed related data to avoid joins. But some data doesn't embed well:
Exchange rates change continuously. If you embed rate_to_usd inside each portfolio document, you'd need to update millions of documents every time a rate moves.
Portfolios reference a currency, and you want to compute USD valuations by looking up the current rate at query time.
This is a classic case where a $lookup join makes sense: a large fact collection (portfolios) joined to a small, frequently-updated reference collection (fxRates). The document model can't avoid this join without accepting stale embedded rates.
Schema and data generation
I created two collections:
portfolios: 5 million documents with a currency field (5 distinct values)
fxRates: 5 documents mapping each currency to its USD exchange rate
I used mongosh to create and load the collection with the following commands:
MongoDB's $lookup combined with $unwind behaves like a LEFT OUTER JOIN followed by filtering out non-matching rows.
Why flexible documents make joins hard
In a relational database, portfolios.currency is a VARCHAR column. The optimizer knows it's a single scalar value per row. It can extract it, hash it, sort it, or probe an index with it — all with well-defined operators.
In a document database, currency might be:
A string: "USD"
An array: ["USD", "EUR"]
Missing entirely
A nested document
MongoDB's $lookup compatibility requires the following behavior:
If localField is an array ["USD", "EUR"], it matches any foreign document where foreignField equals "USD" OR "EUR" (or contains either, if it's also an array).
It's effectively an "any element matches any element" semantic.
This means that the join condition is not always a simple equality a = b, but may involve “any element matches” semantics evaluated at runtime. Instead, the matching logic must evaluate each document's field at runtime, determine whether it's a scalar or an array, and match accordingly.
The safest general approach is a lateral join — executing the inner query for each outer document and passing the current document's field value into the matching function. This is what both MongoDB and DocumentDB for PostgreSQL do.
What happens under the hood (DocumentDB for PostgreSQL)
I use the DocumentDB API in a SQL query rather than the MongoDB-compatible endpoint to view the PostgreSQL execution plan.
Since I joined a large collection with a small one and require all documents from both, I would anticipate a hash join. Instead, it uses a nested loop join:
Nested Loop (actual time=579..64792 rows=5000000 loops=1)
-> Seq Scan on documents_11 collection (rows=5000000 loops=1)
-> Seq Scan on documents_10 collection_0_1 (rows=1 loops=5000000)
Filter: bson_dollar_lookup_join_filter(...)
Rows Removed by Filter: 4
Execution Time: 87750 ms
The fxRates table (5 rows, fitting in a single 8kB block) is scanned 5 million times. PostgreSQL's cost-based optimizer knows the table is tiny and fits in cache, so a sequential scan is the right choice over an index scan — but the scan is still executed 5 million times because of the LATERAL pattern. The filter function bson_dollar_lookup_join_filter is evaluated 25 million times. This function handles array semantics — it extracts the field from the outer document, determines whether it's scalar or an array, and checks for matches in the inner document.
Because the inner side is marked as LATERAL, it depends on the current outer row. This prevents PostgreSQL from evaluating both sides independently, which is required for hash or merge joins. As a result, only a nested loop strategy is possible.
In MongoDB, the equivalent behavior is the IndexedLoopJoin strategy: for each outer document, probe the index on the foreign field. The algorithm and per-document cost are the same.
A note on MongoDB's Hash Join
MongoDB 8.0 can use hash join for $lookup when allowDiskUse: true, no compatible index on the foreign field, the foreign collection is small, and the SBE engine is active. Under these conditions, MongoDB builds an in-memory hash table from the foreign collection, correctly handling array semantics by storing per-element entries.
In tests with 5M portfolios and 5 fxRates, MongoDB's native HashJoin finished in ~14 seconds — the fastest of my tests. Without tweaks, it took 170 seconds — the worst.
To achieve 14 seconds, I dropped the index on the foreign field, enabled allowDiskUse, and set internalQueryFrameworkControl to trySbeEngine. The default trySbeRestricted mode doesn't push the $lookup and $unwind to SBE, since the optimization depends on feature flags that aren't enabled in this mode. With trySbeEngine, SBE handles the pipeline, using HashJoin:
// Setup for hash joindb.adminCommand({setParameter:1,internalQueryFrameworkControl:"trySbeEngine"});db.fxRates.dropIndex("currency_1");// The query (same as all other tests)db.portfolios.aggregate([{$lookup:{from:"fxRates",localField:"currency",foreignField:"currency",as:"fx"}},{$unwind:"$fx"},{$project:{portfolioId:1,valuation:1,currency:1,rate_to_usd:"$fx.rate_to_usd",valuation_usd:{$multiply:["$valuation","$fx.rate_to_usd"]}}}],{allowDiskUse:true}).explain("executionStats");// Restoredb.fxRates.createIndex({currency:1},{unique:true});db.adminCommand({setParameter:1,internalQueryFrameworkControl:"trySbeRestricted"});
DocumentDB for PostgreSQL doesn't currently implement this optimization — it relies on PostgreSQL's native join strategies, which don't understand BSON array semantics. Under normal conditions, both MongoDB and DocumentDB use a Nested Loop join.
Attempting alternatives via the MongoDB API
Using _id as Join Key (~71s)
The documentDB extension has a special case when foreignField is _id — it uses direct object_id equality:
// Reshape fxRates to use currency as _iddb.fxRates.drop();currencies.forEach(cur=>{db.fxRates.insertOne({_id:cur,rate_to_usd:Math.random()*(1.5-0.5)+0.5,last_updated:newDate()});});db.portfolios.aggregate([{$lookup:{from:"fxRates",localField:"currency",foreignField:"_id",as:"fx"}},{$unwind:"$fx"},{$project:{portfolioId:1,valuation:1,currency:1,rate_to_usd:"$fx.rate_to_usd",valuation_usd:{$multiply:["$valuation","$fx.rate_to_usd"]}}}])
It uses an index scan with the join condition applied as an Index Cond, which is more efficient than a sequential scan with a Filter. It's slightly faster, taking 71 seconds instead of 88 seconds, yet it remains a nested loop with 5 million iterations:
Nested Loop (actual time=17..48170 rows=5000000 loops=1)
-> Seq Scan on documents_11 collection (rows=5000000 loops=1)
-> Index Scan using _id_ on documents_12 (rows=1 loops=5000000)
Index Cond: (object_id = ANY (bson_dollar_lookup_extract_filter_array(...)))
Execution Time: 70578 ms
This is the same as MongoDB's IndexedLoopJoin — the _id field is guaranteed to be scalar, so the extension can use a direct equality lookup on the primary key. However, it doesn't change the join strategy.
Uncorrelated $lookup + $filter (~68s)
A minor enhancement involves reading all fxRates at once, using an empty pipeline and no join condition, attaching the data as an array, and then filtering locally:
The execution plan shows a Nested Loop with a single loop:
Nested Loop (actual time=17..20177 rows=5000000 loops=1)
-> Aggregate (rows=1 loops=1) -- reads fxRates ONCE
-> Seq Scan on documents_11 (rows=5000000 loops=1)
Execution Time: 67905 ms (of which ~48s is $addFields+$project)
The join itself is fast — fxRates are aggregated once into a single array. But the per-document $filter + $arrayElemAt evaluates BSON expressions 5 million times. We traded "nested loop probe" for "per-row array scan in BSON space".
This is conceptually similar to the "nested loop with materialization" approach from the previous MongoDB article — reading the lookup collection once, but matching per-document in the projection.
Pipeline-Based $lookup — No Help
Using $lookup with pipeline and let doesn't enhance performance:
The extension still creates a LATERAL join (all code paths set rightTree->lateral = true), and it introduces additional overhead due to variable resolution.
With the MongoDB-compatible API, no solution significantly improves the efficiency of the join. But on DocumentDB, the power of SQL opens new possibilities.
The PostgreSQL escape hatch: SQL with Hash Join
Since DocumentDB stores data in standard PostgreSQL tables, we can query the same collections with SQL—within the same transaction and with full ACID guarantees. The trade-off is that we lose flexible-document join semantics and assume scalar join keys.
Prerequisite: enabling Hash Join for the BSON type (a hack)
The bson type has a hash operator class (bson_hash_ops) used for GROUP BY and DISTINCT. But the =operator doesn't declare hash join support — it's missingHASHES and MERGES properties. This is likely intentional, since bson = bson comparison on full documents has different semantics than field-level equality. But for my investigation (comparing extracted scalar field values), it would work:
-- Requires superuser — this is a hack, not a supported configuration-- If DocumentDB enables this in the future, it will be part of the extensionALTEROPERATORdocumentdb_core.=(documentdb_core.bson,documentdb_core.bson)SET(COMMUTATOR=OPERATOR(documentdb_core.=),HASHES,MERGES);
Without this, PostgreSQL cannot execute hash join for bson = bson conditions, even in custom SQL. However, note that the SQL hash join method, enabled by this hack, does not replicate MongoDB's "any element matches" behavior when joined fields include arrays.
The Query: CTE with Hash Join (~39s)
To utilize a SQL join, I first query the two collections within two common table expressions in the WITH clause, then join them in the main query:
PostgreSQL creates a small 5-row hash table (9 kB) and probes it once per portfolio. It makes a single pass over both collections. Most of the remaining time is spent calling bson_expression_get 5 million times to retrieve the join key, along with bson_dollar_merge_documents_at_path and bson_dollar_project to generate the final output.
In the end, this query is only about twice as fast. It requires a complex workaround, breaks document semantics, and still spends most of its time evaluating BSON expressions.
Results Summary
I started Docker containers as:
# Start MongoDB (minimal setup for ACID transactions)
docker exec-it$(
docker run -d-p 27017:27017 mongo --replSet rs0
) mongosh --eval"rs.initiate()"# Start DocumentDB (PostgreSQL and MongoDB-compatible endpoints)
docker run -d-p 9712:9712 -p 10260:10260 \
ghcr.io/documentdb/documentdb/documentdb-local:latest \--username"franck"--password"franck"--start-pg
Below is a summary of my experiments, run in Docker containers with default configurations, involving 5 million portfolios, 5 fxRates, and a unique index on fxRates.currency:
Approach
MongoDB
DocumentDB
Strategy
$lookup localField/foreignField
~170s
~88s
Nested Loop (lateral index/filter)
$lookup with foreignField: "_id"
~155s
~71s
Nested Loop (index probe)
Uncorrelated $lookup + $filter
~22s
~68s
Materialize once + per-doc filter
SQL CTE + Hash Join (operator tweak)
—
~39s
Hash Join (forced)
HashJoin (SBE, internal tweak)
~14s
—
Hash Join (forced)
MongoDB's native HashJoin via the Slot-Based Execution engine is fastest, handling hash table build/probe natively with per-element array support and avoiding BSON field extraction overhead, but will not be used without configuration tweaks. The DocumentDB SQL escape hatch uses PostgreSQL's optimizer for the same join strategy but incurs overhead with bson_expression_get on each row.
The other solutions are compatible with standard configurations and use appropriate data models and query code. Remember that the time here reflects reading five million documents, and the difference may be insignificant on small datasets.
The Trade-off: Flexibility vs. Optimization
These experiments show the trade-off clearly. Relational systems rely on joins due to normalization, but they can optimize them effectively thanks to typed scalar columns. Document databases avoid many joins, but when joins are needed, flexible semantics—like arrays—limit the available algorithms.
DocumentDB for PostgreSQL sits in the middle. It relies on PostgreSQL storage and execution while preserving MongoDB semantics. As a result, $lookup uses only a subset of SQL's join capabilities to preserve this flexibility. The SQL workaround shows that performance improves when you enforce scalar semantics, but this runs counter to the expectations of a document model, where any field in one document can be an array in another.
So the real question is not which system is faster, but which trade-off you choose: flexibility with embedded arrays or optimization for scalar values.
This was tested on MongoDB 8.0 and DocumentDB 0.112 on PostgreSQL 17.10, and both can improve in the future. Optimization is possible when the field is a known scalar. But if you have a fixed schema, do you still want a document database or switch to SQL? PostgreSQL can also gain optimizations that benefit DocumentDB queries. For example, the lateral join could be memoized in a future version.
If you're thinking about using DocumentDB for PostgreSQL — whether you're migrating from MongoDB or starting fresh — don't stop at the first slow query. Look into the causes, since the trade-off between speed and flexibility can differ. Check execution plans, and file an issue or start a discussion. More feedback from real workloads helps the contributors improve the extension. That's a major advantage of open source.
Running pgvector on Amazon Aurora PostgreSQL gives you a production-grade vector store on a database you already know, backed by the operational tooling, high availability, and scaling behaviour of Amazon Aurora. Production traffic does introduce a predictable set of operational considerations: query latency as the corpus grows, recall on filtered vector searches, memory headroom during index builds, and connection behaviour under load. This post is scoped to the database operations that keep the RAG retrieval layer healthy. In this post, we cover the operational practices that keep a pgvector workload healthy once you depend on it: choosing the right index and distance function, scaling with quantization and partitioning, managing Hierarchical Navigable Small World (HNSW) churn, sizing for memory-resident operation, and the observability signals that catch problems early.
When migrating from Oracle Database to PostgreSQL, the Oracle Foreign Data Wrapper enables PostgreSQL to connect to Oracle and import data as if it were a local table. This extension is not available on all managed PostgreSQL services. For Azure Database for PostgreSQL, setup is straightforward since it runs community PostgreSQL. The only requirement is that extensions must first be enabled via the azure-specific parameter azure.extensions:
Oracle_FDW extension in PostgreSQL
I can create the extension as the administrator and utilize its features:
It's important to understand how PostgreSQL will connect to the Oracle Database: oracle_fdw is installed with the Oracle Instant Client libraries.
An Oracle Database in a lab
I exposed an Oracle database to the internet for lab purposes, so I did not need to configure the network. I set up using the Docker image.
# Start Oracle and expose port 1521 to all interfacessudo docker run -d--name ora -p 1521:1521 -eORACLE_PASSWORD=franck gvenzl/oracle-free:slim
# Open port 1521 in the firewall (you may have to add a rule in the inbound security rules)sudo firewall-cmd --zone=public --permanent--add-port=1521/tcp
sudo firewall-cmd --reload# Starting Oracle Database takes time, wait for it to be upecho"Waiting for Oracle";until docker logs ora |
grep"Completed: Pluggable database FREEPDB1 opened read write"do echo-n".";sleep 1 ;done# Load the SCOTT/TIGER schema example
docker exec-it ora bash -c'
TWO_TASK=//localhost:1521/FREEPDB1 sqlplus system/franck @?/rdbms/admin/utlsampl.sql
'# Allow SCOTT to EXPLAIN PLAN
docker exec-it ora bash -c'
echo "GRANT SELECT_CATALOG_ROLE TO SCOTT;" |
TWO_TASK=//localhost:1521/FREEPDB1 sqlplus system/franck
'# Show the connection string from internetecho"You can connect as: SCOTT/TIGER@//$(curl -s ifconfig.me):1521/FREEPDB1"
Foreign Data Wrapper server and user
Now, back to Azure Database for PostgreSQL, I can specify the Oracle Database server via the connection string, as well as the user and password I use for connection:
When using the VERBOSE option, EXPLAIN executes an EXPLAIN PLAN on the remote server. (I granted SELECT_CATALOG_ROLE to prevent the no SELECT privilege on V$SQL error.)
An EXPLAIN ANALYZE displays the timing of remote calls, which here is approximately 12 milliseconds:
This 12ms latency is expected because my PostgreSQL database is located in Amsterdam (Azure West Europe), while the Oracle Database is in Paris (OCI eu-paris-1).
Network troubleshooting
I can verify the location using the TCP/IP information in the Oracle Database listener.
The IP address embedded in PROGRAM reflects the PostgreSQL client process name, not Oracle network metadata (I used psql in Zurich). The IP in HOST indicates the PostgreSQL client connecting to the Oracle listener, while the first IP from ipinfo.io shows the location of the Oracle Database host.
A latency of 12ms is expected between these two cloud regions:
If I need to troubleshoot, I can run tcpdump -i any port 1521 -nn on the Oracle Database host:
sudo tcpdump -i any port 1521 -nn-tttt-vvv
dropped privs to tcpdump
tcpdump: verbose output suppressed, use -v or -vvfor full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), capture size 262144 bytes
2026-06-24 22:01:44.583852 IP 51.124.207.22.1024 > 10.0.0.59.1521: Flags [S], seq 987172167, win 65535, options [mss 1440,sackOK,TS val 2030652947 ecr 0,nop,wscale 9], length 0
2026-06-24 22:01:44.583952 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [S], seq 987172167, win 65535, options [mss 1440,sackOK,TS val 2030652947 ecr 0,nop,wscale 9], length 0
2026-06-24 22:01:44.583960 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [S], seq 987172167, win 65535, options [mss 1440,sackOK,TS val 2030652947 ecr 0,nop,wscale 9], length 0
2026-06-24 22:01:44.583988 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [S.], seq 944233141, ack 987172168, win 65160, options [mss 1460,sackOK,TS val 2168564814 ecr 2030652947,nop,wscale 7], length 0
2026-06-24 22:01:44.583996 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [S.], seq 944233141, ack 987172168, win 65160, options [mss 1460,sackOK,TS val 2168564814 ecr 2030652947,nop,wscale 7], length 0
2026-06-24 22:01:44.584048 IP 10.0.0.59.1521 > 51.124.207.22.1024: Flags [S.], seq 944233141, ack 987172168, win 65160, options [mss 1460,sackOK,TS val 2168564814 ecr 2030652947,nop,wscale 7], length 0
2026-06-24 22:01:44.602184 IP 51.124.207.22.1024 > 10.0.0.59.1521: Flags [.], ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 0
2026-06-24 22:01:44.602200 IP 51.124.207.22.1024 > 10.0.0.59.1521: Flags [P.], seq 1:75, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 74
2026-06-24 22:01:44.602201 IP 51.124.207.22.1024 > 10.0.0.59.1521: Flags [P.], seq 75:336, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 261
2026-06-24 22:01:44.602241 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [.], ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 0
2026-06-24 22:01:44.602249 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [.], ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 0
2026-06-24 22:01:44.602252 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [P.], seq 1:75, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 74
2026-06-24 22:01:44.602253 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [P.], seq 1:75, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 74
2026-06-24 22:01:44.602254 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [P.], seq 75:336, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 261
2026-06-24 22:01:44.602255 IP 51.124.207.22.1024 > 172.17.0.2.1521: Flags [P.], seq 75:336, ack 1, win 128, options [nop,nop,TS val 2030652960 ecr 2168564814], length 261
2026-06-24 22:01:44.602295 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [.], ack 75, win 509, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.602303 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [.], ack 75, win 509, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.602312 IP 10.0.0.59.1521 > 51.124.207.22.1024: Flags [.], ack 75, win 509, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.602435 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [.], ack 336, win 507, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.602446 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [.], ack 336, win 507, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.602462 IP 10.0.0.59.1521 > 51.124.207.22.1024: Flags [.], ack 336, win 507, options [nop,nop,TS val 2168564833 ecr 2030652960], length 0
2026-06-24 22:01:44.619442 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [P.], seq 1:62, ack 336, win 507, options [nop,nop,TS val 2168564850 ecr 2030652960], length 61
2026-06-24 22:01:44.619470 IP 172.17.0.2.1521 > 51.124.207.22.1024: Flags [P.], seq 1:62, ack 336, win 507, options [nop,nop,TS val 2168564850 ecr 2030652960], length 61
2026-06-24 22:01:44.619493 IP 10.0.0.59.1521 > 51.124.207.22.1024: Flags [P.], seq 1:62, ack 336, win 507, options [nop,nop,TS val 2168564850 ecr 2030652960], length 61
2026-06-24 22:01:44.630863 IP 51.124.207.22.1024 > 10.0.0.59.1521: Flags [.], ack 62, win 128, options [nop,nop,TS val 2030652994 ecr 2168564850], length 0
Duplicate packets originating from 172.17.0.2 are from Docker networking and are captured on both the host and container bridges. This marks the start of a TCP connection from Azure Database for PostgreSQL to Oracle on port 1521, beginning with the TCP handshake. PostgreSQL then sends two TNS packets to Oracle, which acknowledges both. Oracle responds with a 61-byte payload. TCP flags include [S] (connection setup), [S.] (SYN+ACK), [.] (ACK), and [P.] (PSH + ACK). The absence of the U flag indicates Oracle Net isn't using TCP urgent data, resolving previous OOB issues with older clients.
Production connection string
Running the Oracle Database in a lab helps with troubleshooting, but the database you want to migrate is not publicly accessible, and the connection string may be a bit more complex. As the Oracle client is on a managed service, you cannot add files such as tnsnames.ora or an mTLS wallet. I tested with an Oracle Autonomous database.
You need to enable One-Way TLS (encryption without a wallet) by setting Mutual TLS (mTLS) authentication to not required, and add the IP address of the PostgreSQL database to the allow list:
I got this address from the listener log of my previous test.
Then I got the TLS connection string for the Oracle Autonomous database and used it to define the foreign data wrapper server:
After specifying the connection string and credentials, you can read or import tables from Oracle Autonomous into Azure Database for PostgreSQL.
Conclusion
Oracle FDW allows querying an Oracle database from PostgreSQL with minimal setup. On Azure Database for PostgreSQL, the only extra step is to enable the extension. Otherwise, it functions as standard PostgreSQL. This feature is useful for migrations to import or compare data between the two databases. Queries are straightforward, and the planner decides what runs remotely. Performance depends on pushdown to Oracle and network latency. Filters and joins may run remotely or locally, and if pushdown isn’t possible, large result sets may be transferred and joined locally. All this is visible from the execution plan.
Connecting to Oracle can sometimes be challenging. Make sure to run oracle_diag() and verify that both oracle_fdw and the Oracle client are current and compatible with your Oracle database, that the port is opened on the network between the two databases, and that you can connect with a connection string without additional files.
In this post, you learn how to build a Spring Boot REST API that integrates with Aurora DSQL. You’ll configure the Aurora DSQL JDBC Connector for IAM authentication, implement optimistic concurrency control, and run the application across two regional nodes to observe active-active behavior.
Looking at my peak male physique, and my Keanu Reeves baby face, you would never suspect it, but I recently turned 50. As is the tradition, I thought about writing a post titled "50 Lessons at 50". Unfortunately, I don't have that kind of wisdom. The thing is, I still feel like I'm 18, same age as my son. Turns out this is the secret old guys have been hiding from us all along. You get older on the outside, but inside you still see yourself as the same young lad.
Still, fifty years should count for something. So what did I actually learn? How am I different mentally than my 18-year-old self. Here is my attempt to tally it up.
1. Caution is warranted
I finally understand my parents. As you age, you accumulate battle scars, and the scars turn into habits. Anything that can go wrong will go wrong. You forget the cooktop on once, and suddenly you check it three times before leaving the house. True story. You stop diving head-first into a pile of leaves, because there might be a rake underneath. Another true story.
But the trap is overcorrecting. Common sense is good, but too much common sense is dangerous. It makes you overly cautious, predictable, and dull, and it talks you out of the leaf pile even when there is no rake. So, it is important to fight that entropy.
2. Realism helps you understand how the world runs
When I was younger I thought everyone was good, smart, rational, and reasonable. I was idealistic, and I was projecting my own motivations onto everyone else. As a professor, I rarely questioned the administrators. Surely everyone was trying to improve education for the students. Not really. In fact, many of the students were not particularly interested in improving their own education either.
This was a disappointing realization, but then it was also liberating. The world gets much easier to understand once you take off the rose-colored glasses and start watching the incentives instead of the stated intentions. You stop being surprised, and you stop being hurt. You get a model of the world that actually predicts the world. Recognizing where the incentives point doesn't obligate you to like them. You can decide to play the game, or refuse it, or even try to change it.
3. Competence is bliss
I alluded to the Murphy's Law earlier. In 1949, an engineer named Edward Murphy got frustrated after a technician wired the sensors backwards during an Air Force rocket sled experiment. This led to the aphorism "if there is a way to do it wrong, someone will find it". People remember this as a line about bad luck, but it's actually about how easy it is to do things wrong, and how rare it is to do them right.
It took me a couple of decades to fully appreciate that competence is far scarcer than you'd expect. This is both bad news and good news. The bad news is obvious, and all of us are going to experience it many times, at the hands of incompetent parties. The good news is that competence stands out. When you meet genuinely competent people you notice immediately, and you appreciate them. They are reliable. They sweat the details and understand the tradeoffs. You start seeking them out, and when you find them, friendships form fast, because you both know how unusual the thing actually is.
4. Do what you like
I've always wanted an excuse to use the Bell Curve meme, so here it goes.
Most people dramatically overestimate their ability to predict what will matter in ten years or even five years. The elaborate plans are mostly a waste of time. The best strategy is work on what you like, and what you find interesting and energizing, because curiosity, enthusiasm, and the craft compounds. The safest long-term strategy is often to become exceptionally good at something you genuinely enjoy.
"Find out who you are and do it on purpose." --Dolly Parton
5. Your attitude determines your success
I am not likely to turn into the old guy yelling at the cloud any time soon, as I am aware of the danger of learned pessimism and helplessness. So I care about this lesson more than the others. Your attitude determines your success, and the stories you tell yourself matter. Since our emotions drive our persistence and our willingness to keep going, it pays to keep a constructive narrative about setbacks and challenges.
I aim for cautious optimism. I am realistic about the risks, but still optimistic about the possibilities. I am jaded enough to see how the world works, but still idealistic enough to want to improve it. Years ago I wrote a post called Fool Yourself, about deliberately choosing the narrative that gives you energy and momentum. I still believe that. At 50 I'm a little wiser, certainly more cautious and aware of the challenges ahead, but I am still fooling myself into chasing things I find meaningful.