<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged query-optimization</title>
<link>https://coderlegion.com/tag/query-optimization</link>
<description>Powered by Question2Answer</description>
<item>
<title>INNER JOIN vs Correlated Subquery in SQL: What Is Actually Faster?</title>
<link>https://coderlegion.com/21160/inner-join-vs-correlated-subquery-in-sql-what-is-actually-faster</link>
<description>&lt;p&gt;Most SQL developers hear the same rule early: always prefer &lt;code&gt;INNER JOIN&lt;/code&gt; over correlated subqueries, because subqueries create an N+1 performance problem.&lt;/p&gt;
&lt;p&gt;That rule is useful, but it is not always true.&lt;/p&gt;
&lt;p&gt;I tested this on four engines (MySQL, Oracle, PostgreSQL, SQLite) and got mixed results. In some cases, the correlated subquery was faster than the join query.&lt;/p&gt;
&lt;p&gt;If you want to validate the same patterns yourself, you can run these examples in live sandboxes and practice similar SQL tasks on &lt;a rel=&quot;nofollow&quot; href=&quot;https://sqltest.online&quot;&gt;sqltest.online&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-you-will-learn&quot;&gt;What You Will Learn&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;what &lt;code&gt;INNER JOIN&lt;/code&gt; does best;&lt;/li&gt;
&lt;li&gt;when a correlated subquery can be competitive;&lt;/li&gt;
&lt;li&gt;why execution plans matter more than &quot;golden rules&quot;;&lt;/li&gt;
&lt;li&gt;how to benchmark both approaches correctly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-is-inner-join-in-sql-&quot;&gt;What Is INNER JOIN in SQL?&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;INNER JOIN&lt;/code&gt; returns only rows that match in both tables.&lt;/p&gt;
&lt;p&gt;Why it matters: it is usually the cleanest set-based way to combine data, and often gives the optimizer strong options.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    c.customer_id,
    c.name,
    o.order_id
FROM customers c
INNER JOIN orders o
    ON c.customer_id = o.customer_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In many workloads, this is exactly what you want. But performance still depends on table sizes, indexes, and optimizer decisions.&lt;/p&gt;
&lt;h3 id=&quot;benchmark-scenario&quot;&gt;Benchmark Scenario&lt;/h3&gt;
&lt;p&gt;I used a small outer table and a larger inner table:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;customers&lt;/code&gt;: 25 rows&lt;/li&gt;
&lt;li&gt;&lt;code&gt;orders&lt;/code&gt;: 1000 rows&lt;/li&gt;
&lt;li&gt;index on &lt;code&gt;orders(customer_id)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Task: return each customer with the number of their orders.&lt;/p&gt;
&lt;h3 id=&quot;two-query-patterns&quot;&gt;Two Query Patterns&lt;/h3&gt;
&lt;h4 id=&quot;pattern-a-join-group-by&quot;&gt;Pattern A: JOIN + GROUP BY&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    c.customer_id,
    COUNT(o.order_id) AS orders_count
FROM customers c
LEFT JOIN orders o
    ON c.customer_id = o.customer_id
GROUP BY c.customer_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note: I used &lt;code&gt;LEFT JOIN&lt;/code&gt; here to keep customers with zero orders. If you only need matched rows, use &lt;code&gt;INNER JOIN&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;pattern-b-correlated-subquery&quot;&gt;Pattern B: Correlated Subquery&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    c.customer_id,
    (
        SELECT COUNT(o.order_id)
        FROM orders o
        WHERE o.customer_id = c.customer_id
    ) AS orders_count
FROM customers c;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second form looks like classic N+1, but an index can make each lookup very cheap.&lt;/p&gt;
&lt;h3 id=&quot;how-to-inspect-the-real-cost&quot;&gt;How to Inspect the Real Cost&lt;/h3&gt;
&lt;p&gt;Use execution plans, not assumptions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MySQL / PostgreSQL: &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;SQLite: &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Oracle: &lt;code&gt;EXPLAIN PLAN FOR ...&lt;/code&gt; + &lt;code&gt;DBMS_XPLAN.DISPLAY&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;results-summary-across-engines&quot;&gt;Results Summary Across Engines&lt;/h3&gt;
&lt;h4 id=&quot;mysql-8-and-oracle-23c&quot;&gt;MySQL 8 and Oracle 23c&lt;/h4&gt;
&lt;p&gt;In my tests, the correlated subquery often won.&lt;/p&gt;
&lt;p&gt;Why: with a small outer table (25 customers), the optimizer could do very fast indexed lookups on &lt;code&gt;orders(customer_id)&lt;/code&gt; per customer. That was cheaper than building larger join/group intermediate work.&lt;/p&gt;
&lt;h4 id=&quot;postgresql-16&quot;&gt;PostgreSQL 16&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;JOIN + GROUP BY&lt;/code&gt; won in my test.&lt;/p&gt;
&lt;p&gt;Why: PostgreSQL produced an efficient set-based plan and handled the join/aggregate path very well.&lt;/p&gt;
&lt;h4 id=&quot;sqlite&quot;&gt;SQLite&lt;/h4&gt;
&lt;p&gt;Practical tie.&lt;/p&gt;
&lt;p&gt;Both variants ended with very similar plans and near-identical timing on this dataset.&lt;/p&gt;
&lt;h3 id=&quot;why-the-inner-join-rule-sometimes-fails&quot;&gt;Why the INNER JOIN Rule Sometimes Fails&lt;/h3&gt;
&lt;p&gt;The &quot;always use INNER JOIN&quot; advice fails when it ignores three variables:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Database engine optimizer behavior&lt;/li&gt;
&lt;li&gt;Data distribution and table size ratio&lt;/li&gt;
&lt;li&gt;Index availability and quality&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;SQL performance is not about syntax preference. It is about the physical plan your engine chooses.&lt;/p&gt;
&lt;h3 id=&quot;common-mistakes&quot;&gt;Common Mistakes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;assuming &lt;code&gt;INNER JOIN&lt;/code&gt; is always faster without checking plans;&lt;/li&gt;
&lt;li&gt;forgetting that &lt;code&gt;INNER JOIN&lt;/code&gt; removes non-matching rows;&lt;/li&gt;
&lt;li&gt;testing once without warm-up runs;&lt;/li&gt;
&lt;li&gt;benchmarking without a supporting index;&lt;/li&gt;
&lt;li&gt;comparing query text instead of comparing actual execution plans.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;practical-checklist&quot;&gt;Practical Checklist&lt;/h3&gt;
&lt;p&gt;Before choosing &lt;code&gt;INNER JOIN&lt;/code&gt; or correlated subquery:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add the right index (here: &lt;code&gt;orders(customer_id)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Run both query variants.&lt;/li&gt;
&lt;li&gt;Capture execution plans.&lt;/li&gt;
&lt;li&gt;Compare timing over several runs.&lt;/li&gt;
&lt;li&gt;Pick the query that is both correct and fast for your engine.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;final-takeaway&quot;&gt;Final Takeaway&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;INNER JOIN&lt;/code&gt; is a strong default and often the right choice, but it is not a universal performance guarantee.&lt;/p&gt;
&lt;p&gt;The best query is the one that produces the best execution plan on your actual database, with your actual data.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21160/inner-join-vs-correlated-subquery-in-sql-what-is-actually-faster</guid>
<pubDate>Mon, 22 Jun 2026 20:57:21 +0000</pubDate>
</item>
<item>
<title>Oracle AI Vector Search: Querying Vectors and Optimizing with Indexes</title>
<link>https://coderlegion.com/11750/oracle-ai-vector-search-querying-vectors-and-optimizing-with-indexes</link>
<description>&lt;p&gt;Oracle Database 23ai's AI Vector Search enables powerful semantic search capabilities, but to truly harness its potential, you need to understand how to query vector data efficiently and when to use vector indexes. This guide explores querying vectors and optimizing performance with specialized vector indexes.&lt;/p&gt;
&lt;h3 id=&quot;running-basic-queries-on-vectors&quot;&gt;Running Basic Queries on Vectors&lt;/h3&gt;
&lt;h4 id=&quot;combining-vector-and-relational-data&quot;&gt;Combining Vector and Relational Data&lt;/h4&gt;
&lt;p&gt;One of Oracle AI Vector Search's most powerful features is the ability to combine vector data with traditional relational data in a single query. This unified approach eliminates data fragmentation and allows you to perform semantic searches alongside business logic filters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Basic Query Pattern:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Create table with both relational and vector data
CREATE TABLE products (
    product_id NUMBER PRIMARY KEY,
    product_name VARCHAR2(200),
    description CLOB,
    price NUMBER(10,2),
    category VARCHAR2(100),
    in_stock VARCHAR2(1),
    description_vector VECTOR(384, FLOAT32)
);

-- Insert sample data
INSERT INTO products VALUES (
    1001,
    'Wireless Headphones',
    'Premium noise-cancelling Bluetooth headphones with 30-hour battery life',
    299.99,
    'Electronics',
    'Y',
    VECTOR_EMBEDDING(doc_model USING 
        'Premium noise-cancelling Bluetooth headphones with 30-hour battery life' AS data)
);

-- Query combining vector similarity with relational filters
SELECT 
    product_name,
    price,
    category,
    VECTOR_DISTANCE(
        description_vector,
        VECTOR_EMBEDDING(doc_model USING 'wireless audio device' AS data),
        COSINE
    ) AS similarity_score
FROM products
WHERE price &amp;lt; 500
    AND in_stock = 'Y'
    AND category = 'Electronics'
ORDER BY similarity_score
FETCH FIRST 10 ROWS ONLY;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;selecting-vector-data&quot;&gt;Selecting Vector Data&lt;/h4&gt;
&lt;p&gt;You can select vector data using the asterisk (*) keyword just like any other column:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Select all columns including vectors
SELECT * FROM products;

-- Select specific columns including the vector
SELECT product_id, product_name, description_vector 
FROM products;

-- Vector dimensions can be extracted
SELECT 
    product_name,
    VECTOR_DIMENSION_COUNT(description_vector) AS dimensions,
    VECTOR_DIMENSION_FORMAT(description_vector) AS format
FROM products;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;important-restriction-no-comparison-operators-between-vectors&quot;&gt;Important Restriction: No Comparison Operators Between Vectors&lt;/h4&gt;
&lt;p&gt;Standard comparison operators (such as =, &amp;gt;, &amp;lt;, &amp;gt;=, &amp;lt;=, !=) are not allowed between vectors. You cannot directly compare vectors using these operators.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; Invalid Operations:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- These will cause errors
SELECT * FROM products WHERE description_vector = other_vector;
SELECT * FROM products WHERE description_vector &amp;gt; other_vector;
SELECT * FROM products WHERE description_vector &amp;lt; other_vector;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt; Valid Operations:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead, use specialized vector distance functions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Correct: Use VECTOR_DISTANCE function
SELECT 
    product_name,
    VECTOR_DISTANCE(description_vector, :query_vector, COSINE) AS distance
FROM products
ORDER BY distance
FETCH FIRST 5 ROWS ONLY;

-- Correct: Use shorthand operators
SELECT product_name
FROM products
ORDER BY description_vector &amp;lt;=&amp;gt; :query_vector  -- Cosine distance
FETCH FIRST 5 ROWS ONLY;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;understanding-vector-indexes&quot;&gt;Understanding Vector Indexes&lt;/h3&gt;
&lt;p&gt;Vector indexes are specialized data structures that dramatically improve query performance on vector data. Without indexes, every similarity search performs an exhaustive comparison against all vectors in the table—a process that becomes prohibitively slow as datasets grow.&lt;/p&gt;
&lt;h4 id=&quot;why-vector-indexes-matter&quot;&gt;Why Vector Indexes Matter&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;The Problem:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exhaustive Search&lt;/strong&gt;: Compares query vector against every vector in the table&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time Complexity&lt;/strong&gt;: O(n) - grows linearly with dataset size&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real Impact&lt;/strong&gt;: Searches that take seconds can become hours-long operations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The Solution:&lt;/strong&gt;&lt;br&gt;
Vector indexes use sophisticated techniques to reduce the search space:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clustering&lt;/strong&gt;: Groups similar vectors together&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partitioning&lt;/strong&gt;: Divides the vector space into manageable regions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Neighbor Graphs&lt;/strong&gt;: Creates efficient navigation paths through vector space&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The Result:&lt;/strong&gt;&lt;br&gt;
Vector indexes greatly reduce search space, making searches appear quick and extremely efficient, often reducing search time from hours to milliseconds.&lt;/p&gt;
&lt;h3 id=&quot;types-of-vector-indexes&quot;&gt;Types of Vector Indexes&lt;/h3&gt;
&lt;p&gt;Oracle AI Vector Search supports three types of vector indexes:&lt;/p&gt;
&lt;h4 id=&quot;1-in-memory-neighbor-graph-hnsw-index&quot;&gt;1. In-Memory Neighbor Graph (HNSW) Index&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;HNSW (Hierarchical Navigable Small World)&lt;/strong&gt; is a graph-based index that provides the fastest query performance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Characteristics:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In-memory only index that can require a lot of memory for large datasets&lt;/li&gt;
&lt;li&gt;Extremely fast query performance (milliseconds)&lt;/li&gt;
&lt;li&gt;Uses hierarchical layers for efficient navigation&lt;/li&gt;
&lt;li&gt;Best for datasets that fit in available memory&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Creating an HNSW Index:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE VECTOR INDEX product_hnsw_idx ON products (description_vector)
ORGANIZATION INMEMORY NEIGHBOR GRAPH
DISTANCE COSINE
WITH TARGET ACCURACY 95;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Memory Requirements:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To roughly determine the memory size needed to store an HNSW index, use the following formula: 1.3 &lt;em&gt; number of vectors &lt;/em&gt; number of dimensions * size of your vector dimension type&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example Calculation:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1 million vectors × 384 dimensions × 4 bytes (FLOAT32) × 1.3 = ~2 GB
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-neighbor-partition-ivf-index&quot;&gt;2. Neighbor Partition (IVF) Index&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;IVF (Inverted File Flat)&lt;/strong&gt; is a storage-based index that balances search quality with reasonable speed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Characteristics:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Storage-based index not constrained by memory like HNSW&lt;/li&gt;
&lt;li&gt;Uses buffer cache and disk storage&lt;/li&gt;
&lt;li&gt;Can be used for very large datasets and still provide excellent performance compared to exhaustive similarity search&lt;/li&gt;
&lt;li&gt;Supports global and local partitioning&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Creating an IVF Index:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE VECTOR INDEX product_ivf_idx ON products (description_vector)
ORGANIZATION NEIGHBOR PARTITIONS
DISTANCE COSINE
WITH TARGET ACCURACY 95;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;When to Use IVF:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dataset is too large to fit in memory&lt;/li&gt;
&lt;li&gt;RAM is limited or constrained&lt;/li&gt;
&lt;li&gt;Need to support very large (billion-scale) datasets&lt;/li&gt;
&lt;li&gt;Working with partitioned tables&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-hybrid-vector-index&quot;&gt;3. Hybrid Vector Index&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Hybrid indexes&lt;/strong&gt; combine full-text search with semantic vector search in a single index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Creating a Hybrid Index:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE VECTOR INDEX product_hybrid_idx ON products (description)
ORGANIZATION HYBRID
PARAMETERS('EMBEDDING MODEL doc_model');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Benefits:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single index for both keyword and semantic searches&lt;/li&gt;
&lt;li&gt;Run textual queries, vector similarity queries, or hybrid queries&lt;/li&gt;
&lt;li&gt;Ideal for applications needing both search approaches&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;vector-pool-memory-configuration&quot;&gt;Vector Pool Memory Configuration&lt;/h3&gt;
&lt;p&gt;The Vector Pool is memory allocated in SGA to store Hierarchical Navigable Small World (HNSW) vector indexes and all associated metadata. Proper configuration is essential for HNSW index performance.&lt;/p&gt;
&lt;h4 id=&quot;configuring-vector-memory-size&quot;&gt;Configuring Vector Memory Size&lt;/h4&gt;
&lt;h5 id=&quot;at-cdb-container-database-level&quot;&gt;At CDB (Container Database) Level&lt;/h5&gt;
&lt;p&gt;At the CDB level, VECTOR_MEMORY_SIZE specifies the current size of the Vector Pool:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Set vector memory size at CDB level
ALTER SYSTEM SET VECTOR_MEMORY_SIZE = 1G SCOPE=BOTH;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; A database restart is required to apply this parameter change.&lt;/p&gt;
&lt;h5 id=&quot;at-pdb-pluggable-database-level&quot;&gt;At PDB (Pluggable Database) Level&lt;/h5&gt;
&lt;p&gt;The maximum PDB VECTOR_MEMORY_SIZE value is limited to 70% of the PDB sga_target. PDBs inherit settings from the CDB but can specify their own maximum usage.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Check current SGA target
SELECT value FROM V$PARAMETER WHERE name='sga_target';

-- PDB inherits from CDB and can set maximum
-- Maximum = 70% of PDB's sga_target
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;automatic-vector-pool-growth&quot;&gt;Automatic Vector Pool Growth&lt;/h4&gt;
&lt;p&gt;Oracle 23ai supports automatic vector pool management:&lt;/p&gt;
&lt;p&gt;If VECTOR_MEMORY_SIZE is set to 1 and the sga_target is greater than 0 at CDB initialization, HNSW index creation will automatically grow the vector memory pool to satisfy the new index&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Enable automatic growth
ALTER SYSTEM SET VECTOR_MEMORY_SIZE = 1 SCOPE=SPFILE;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;memory-considerations&quot;&gt;Memory Considerations&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Important Points:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vector indexes are stored in this pool along with vector metadata&lt;/li&gt;
&lt;li&gt;HNSW indexes require significant RAM&lt;/li&gt;
&lt;li&gt;RAM constrains the vector index size&lt;/li&gt;
&lt;li&gt;IVF indexes use buffer cache and disk, not heavily dependent on Vector Pool&lt;/li&gt;
&lt;li&gt;IVF indexes use memory either from the vector pool if defined or the shared pool to speed up index creation and during DML operations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Monitoring Vector Pool:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Check vector pool allocation and usage
SELECT 
    pool,
    alloc_bytes,
    used_bytes,
    populate_status
FROM V$VECTOR_MEMORY_POOL;

-- Check individual index memory usage
SELECT 
    owner,
    index_name,
    index_organization,
    allocated_bytes,
    used_bytes,
    num_vectors
FROM V$VECTOR_INDEX;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;choosing-the-right-index-type&quot;&gt;Choosing the Right Index Type&lt;/h3&gt;
&lt;h4 id=&quot;decision-matrix&quot;&gt;Decision Matrix&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Factor &lt;/th&gt;&lt;th&gt;HNSW &lt;/th&gt;&lt;th&gt;IVF &lt;/th&gt;&lt;th&gt;Hybrid&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Speed&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Fastest (milliseconds) &lt;/td&gt;&lt;td&gt;Fast (seconds) &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;High (in-memory) &lt;/td&gt;&lt;td&gt;Low (disk-based) &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Accuracy&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Highest (95-99%+) &lt;/td&gt;&lt;td&gt;High (90-99%) &lt;/td&gt;&lt;td&gt;Variable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dataset Size&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Small to Medium &lt;/td&gt;&lt;td&gt;Large to Huge &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best For&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Speed-critical apps &lt;/td&gt;&lt;td&gt;Memory-constrained environments &lt;/td&gt;&lt;td&gt;Mixed query types&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;practical-guidelines&quot;&gt;Practical Guidelines&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Use HNSW When:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dataset fits comfortably in available memory&lt;/li&gt;
&lt;li&gt;Query speed is critical (real-time applications)&lt;/li&gt;
&lt;li&gt;You can allocate sufficient Vector Pool memory&lt;/li&gt;
&lt;li&gt;Working with up to millions of vectors&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use IVF When:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dataset is too large for available memory&lt;/li&gt;
&lt;li&gt;Working with billions of vectors&lt;/li&gt;
&lt;li&gt;RAM is limited or expensive&lt;/li&gt;
&lt;li&gt;Using partitioned tables for very large datasets&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use Hybrid When:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Application needs both keyword and semantic search&lt;/li&gt;
&lt;li&gt;Users perform mixed query types&lt;/li&gt;
&lt;li&gt;Want to simplify index management&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;performance-optimization-tips&quot;&gt;Performance Optimization Tips&lt;/h3&gt;
&lt;h4 id=&quot;1-index-configuration&quot;&gt;1. Index Configuration&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Target Accuracy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Higher accuracy = slower but more precise
WITH TARGET ACCURACY 99;  -- Very precise

-- Lower accuracy = faster but less precise  
WITH TARGET ACCURACY 90;  -- Still good for most cases
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Balance accuracy with performance needs. TARGET ACCURACY between 90-99% provides excellent results.&lt;/p&gt;
&lt;h4 id=&quot;2-memory-allocation&quot;&gt;2. Memory Allocation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Allocate adequate memory for HNSW indexes
-- Use formula: 1.3 × vectors × dimensions × format_size

-- Example for 1M vectors, 384 dimensions, FLOAT32:
-- 1.3 × 1,000,000 × 384 × 4 = ~2 GB
ALTER SYSTEM SET VECTOR_MEMORY_SIZE = 2G SCOPE=BOTH;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-query-optimization&quot;&gt;3. Query Optimization&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Use FETCH FIRST for better performance
SELECT product_name, similarity_score
FROM (
    SELECT 
        product_name,
        VECTOR_DISTANCE(description_vector, :query_vec, COSINE) AS similarity_score
    FROM products
    WHERE category = 'Electronics'  -- Apply filters early
)
ORDER BY similarity_score
FETCH FIRST 10 ROWS ONLY;  -- Limit results
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;4-approximate-vs-exact-search&quot;&gt;4. Approximate vs. Exact Search&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Approximate search (uses index, faster)
SELECT product_name
FROM products
ORDER BY VECTOR_DISTANCE(description_vector, :query_vec, COSINE)
FETCH FIRST 10 ROWS ONLY;

-- Exact search (no index, 100% accurate, slower)
SELECT product_name
FROM products
ORDER BY VECTOR_DISTANCE(description_vector, :query_vec, COSINE)
FETCH EXACT FIRST 10 ROWS ONLY;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use approximate search for most queries; reserve exact search for critical accuracy requirements.&lt;/p&gt;
&lt;h3 id=&quot;real-world-example-e-commerce-product-search&quot;&gt;Real-World Example: E-commerce Product Search&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Step 1: Create table
CREATE TABLE products (
    product_id NUMBER PRIMARY KEY,
    name VARCHAR2(500),
    description CLOB,
    price NUMBER(10,2),
    category VARCHAR2(100),
    rating NUMBER(3,2),
    reviews_count NUMBER,
    desc_vector VECTOR(384, FLOAT32)
);

-- Step 2: Configure memory (1M products × 384 dims × 4 bytes × 1.3 = ~2GB)
ALTER SYSTEM SET VECTOR_MEMORY_SIZE = 2G SCOPE=BOTH;

-- Step 3: Create HNSW index
CREATE VECTOR INDEX products_hnsw_idx ON products (desc_vector)
ORGANIZATION INMEMORY NEIGHBOR GRAPH
DISTANCE COSINE
WITH TARGET ACCURACY 95;

-- Step 4: Query with combined filters
SELECT 
    name,
    price,
    rating,
    VECTOR_DISTANCE(
        desc_vector,
        VECTOR_EMBEDDING(model USING 'comfortable running shoes' AS data),
        COSINE
    ) AS relevance
FROM products
WHERE price BETWEEN 50 AND 200
    AND category = 'Footwear'
    AND rating &amp;gt;= 4.0
    AND reviews_count &amp;gt; 100
ORDER BY relevance
FETCH FIRST 10 ROWS ONLY;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;key-takeaways&quot;&gt;Key Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Queries can combine vector and relational data&lt;/strong&gt; in a single SQL statement, eliminating data fragmentation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Standard comparison operators (=, &amp;gt;, &amp;lt;) cannot be used between vectors&lt;/strong&gt; - use VECTOR_DISTANCE functions instead&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector indexes dramatically improve performance&lt;/strong&gt; by reducing search space through clustering, partitioning, and graphs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HNSW indexes&lt;/strong&gt; provide fastest queries but require significant memory (Formula: 1.3 × vectors × dimensions × format_size)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IVF indexes&lt;/strong&gt; work for very large datasets using disk storage, not constrained by RAM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector Pool memory must be configured&lt;/strong&gt; for HNSW indexes at CDB level with VECTOR_MEMORY_SIZE parameter&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database restart required&lt;/strong&gt; when changing VECTOR_MEMORY_SIZE (unless using automatic growth)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PDB maximum is 70% of sga_target&lt;/strong&gt;, inheriting from CDB configuration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose index type based on dataset size and memory availability&lt;/strong&gt; - HNSW for speed, IVF for scale&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By understanding these concepts and properly configuring vector indexes, you can build high-performance semantic search applications that seamlessly integrate AI capabilities with traditional relational database operations.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11750/oracle-ai-vector-search-querying-vectors-and-optimizing-with-indexes</guid>
<pubDate>Mon, 16 Feb 2026 08:23:55 +0000</pubDate>
</item>
<item>
<title>We Measured the N+1 Query Problem. The Numbers Are Worse Than You Think.</title>
<link>https://coderlegion.com/11471/we-measured-the-n-1-query-problem-the-numbers-are-worse-than-you-think</link>
<description>&lt;p&gt;I kept seeing the same advice in every ORM tutorial: &quot;Watch out for N+1 queries.&quot; Every code review, every blog post, every conference talk. Always the same warning, never the actual numbers.&lt;/p&gt;
&lt;p&gt;So I decided to measure it.&lt;/p&gt;
&lt;p&gt;I built a benchmark suite, seeded a PostgreSQL database, wrote the &quot;bad&quot; code that everyone warns about, wrote the &quot;good&quot; code that everyone recommends, and ran them side by side. Four different scenarios, each one timed and query-counted down to the individual SQL statement.&lt;/p&gt;
&lt;p&gt;The short version? On a tiny dataset — just 100 users — the worst pattern was &lt;strong&gt;22x slower&lt;/strong&gt; and fired &lt;strong&gt;200 unnecessary queries&lt;/strong&gt;. And that's the &lt;em&gt;conservative&lt;/em&gt; estimate, because I ran everything on localhost where network latency is basically zero.&lt;/p&gt;
&lt;p&gt;Here's what I found.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-the-n-1-problem-actually-&quot;&gt;What's the N+1 problem, actually?&lt;/h3&gt;
&lt;p&gt;You probably already know this, but it's worth being precise about it because the devil is in the details.&lt;/p&gt;
&lt;p&gt;Say you've got 100 users and you want each user's posts. The naive approach does this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;One query to grab all users&lt;/li&gt;
&lt;li&gt;Then, inside a loop, one query &lt;em&gt;per user&lt;/em&gt; to get their posts&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That's 101 database round-trips. The &quot;1&quot; to get the users, plus &quot;N&quot; (100) to get each user's posts. Hence, N+1.&lt;/p&gt;
&lt;p&gt;The thing is, each individual query is fast — sub-millisecond in most cases. But every single one of those queries carries overhead. The app has to send the request to PostgreSQL, PostgreSQL has to parse it, plan it, execute it, serialise the results, and send them back. Do that 101 times and the overhead stacks up fast.&lt;/p&gt;
&lt;p&gt;The fix is usually straightforward: tell your ORM to grab the related data upfront. In Prisma, that's the &lt;code&gt;include&lt;/code&gt; keyword. Instead of 101 queries, you get 2. Same result, fraction of the time.&lt;/p&gt;
&lt;p&gt;But &lt;em&gt;how much&lt;/em&gt; of a fraction? That's what I wanted to pin down.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-i-set-this-up&quot;&gt;How I set this up&lt;/h3&gt;
&lt;p&gt;I wanted the benchmarks to be fair and reproducible, so here's what I did.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; Prisma 6.3.1, PostgreSQL 17, TypeScript, Node.js 22 — all running locally on the same machine. Running the database locally means there's no network hop between the app and the database. That makes the numbers &lt;em&gt;more conservative&lt;/em&gt; than what you'd see in production, where the database is usually on a separate server (or across the internet, if you're using a managed service).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The data:&lt;/strong&gt; 100 users, each with 3 posts, 2 comments per post, and 2 orders. All generated with Faker so the string content is realistic — not just &lt;code&gt;&quot;test&quot;&lt;/code&gt; repeated a thousand times. Serialisation costs are real costs, and I didn't want to cheat there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The measurement:&lt;/strong&gt; Every query that Prisma sends to PostgreSQL gets intercepted and counted. Not estimated, not sampled — counted. Each benchmark runs 5 times (after a warmup run) using &lt;code&gt;performance.now()&lt;/code&gt;, and I report the mean, median, P95, and P99.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The test cases:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;# &lt;/th&gt;&lt;th&gt;What it tests &lt;/th&gt;&lt;th&gt;Relationship&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1 &lt;/td&gt;&lt;td&gt;Users with their posts &lt;/td&gt;&lt;td&gt;One-to-many&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2 &lt;/td&gt;&lt;td&gt;Users → posts → comments &lt;/td&gt;&lt;td&gt;Three-level nesting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3 &lt;/td&gt;&lt;td&gt;Orders with their user &lt;/td&gt;&lt;td&gt;Many-to-one (reverse)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4 &lt;/td&gt;&lt;td&gt;Active orders, conditionally load user &lt;/td&gt;&lt;td&gt;Conditional access&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each test case has a &quot;bad&quot; version (the N+1 pattern) and a &quot;good&quot; version (the recommended fix). Both return the exact same data.&lt;/p&gt;
&lt;p&gt;Source code for everything: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/liangk/empirical-study&quot;&gt;github.com/liangk/empirical-study&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc1-users-with-posts-the-textbook-case&quot;&gt;TC1: Users with posts — the textbook case&lt;/h3&gt;
&lt;p&gt;This is the one everyone uses as an example. Grab all users, loop through them, fetch posts for each one.&lt;/p&gt;
&lt;p&gt;The bad version fires 101 queries. The good version uses &lt;code&gt;include: { posts: true }&lt;/code&gt; and fires 2.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;101 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;98.0% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;39.31 ms &lt;/td&gt;&lt;td&gt;4.28 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9.2x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;38.96 ms &lt;/td&gt;&lt;td&gt;4.21 ms &lt;/td&gt;&lt;td&gt;9.3x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;41.49 ms &lt;/td&gt;&lt;td&gt;4.53 ms &lt;/td&gt;&lt;td&gt;9.2x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Here's what caught my attention: the variance in the good version is tiny. The standard deviation is under 0.25ms. That matters if you're building an API where you care about tail latency — and you should care about tail latency.&lt;br&gt;
The bad version stays surprisingly steady too, with a relatively small variance. It &lt;em&gt;feels&lt;/em&gt; fine at this scale. 39ms isn't going to trigger any alarms. But it's doing 50x more work than it needs to, and the cost grows linearly with your user count.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc2-nested-relationships-where-it-gets-ugly&quot;&gt;TC2: Nested relationships — where it gets ugly&lt;/h3&gt;
&lt;p&gt;This one goes deeper. Users → posts → comments. Three levels of entities.&lt;/p&gt;
&lt;p&gt;The bad code has two nested loops, each firing queries. With 100 users and 3 posts each, that's 1 query for users + 100 for posts + 300 for comments = &lt;strong&gt;401 queries&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The good version? Nested &lt;code&gt;include&lt;/code&gt; directives. &lt;strong&gt;3 queries total.&lt;/strong&gt; One per entity level, no matter how many records.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;401 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;99.3% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;153.59 ms &lt;/td&gt;&lt;td&gt;10.50 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.6x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;143.21 ms &lt;/td&gt;&lt;td&gt;10.07 ms &lt;/td&gt;&lt;td&gt;14.2x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;185.53 ms &lt;/td&gt;&lt;td&gt;11.82 ms &lt;/td&gt;&lt;td&gt;15.7x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;401 queries versus 3. That's not a percentage I need to calculate to know it's bad.&lt;/p&gt;
&lt;p&gt;But the timing tells a more interesting story. The bad version's P95 is 185ms — that's 29% higher than its median. Some of those runs are hitting extra overhead, probably connection pool pressure or plan cache misses from hammering PostgreSQL with 400 queries in rapid succession. The good version's P95 is only 17% above its median. Fewer queries means less contention, which means more predictable performance.&lt;/p&gt;
&lt;p&gt;And this is the part that should worry you: 153ms with 100 users. The N+1 cost here isn't linear — it's multiplicative. Each level of nesting multiplies the query count. The general formula looks something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Queries = 1 + N + (N × posts_per_user) + (N × posts_per_user × comments_per_post) + ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With eager loading, it's just the number of entity levels + 1. Always. Doesn't matter if you've got 100 users or 100,000.&lt;/p&gt;
&lt;p&gt;If you're thinking &quot;well, my app doesn't have three levels of nesting&quot; — check again. User → projects → tasks. Store → products → reviews. Organization → teams → members. It's more common than you'd expect.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc3-orders-user-the-biggest-speedup&quot;&gt;TC3: Orders → User — the biggest speedup&lt;/h3&gt;
&lt;p&gt;This one flips the relationship. Instead of fetching children for each parent, you're fetching the parent for each child. Classic scenario: you've got an orders list and you want to show the customer name next to each order.&lt;/p&gt;
&lt;p&gt;The bad version grabs all orders (200 of them — 2 per user), then calls &lt;code&gt;findUnique&lt;/code&gt; for each order's user. That's 201 queries.&lt;/p&gt;
&lt;p&gt;The good version adds &lt;code&gt;include: { user: true }&lt;/code&gt;. Two queries.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Eager) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;201 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;99.0% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;82.54 ms &lt;/td&gt;&lt;td&gt;3.72 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;22.2x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;86.10 ms &lt;/td&gt;&lt;td&gt;3.66 ms &lt;/td&gt;&lt;td&gt;23.5x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;88.71 ms &lt;/td&gt;&lt;td&gt;4.02 ms &lt;/td&gt;&lt;td&gt;22.1x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;22.2x.&lt;/strong&gt; That's the largest speedup across all four test cases.&lt;/p&gt;
&lt;p&gt;Why so much bigger than TC1? Two reasons. First, there are more records involved — 200 orders versus 100 users. More queries means more wasted overhead. Second, many of those &lt;code&gt;findUnique&lt;/code&gt; calls are fetching the &lt;em&gt;same user&lt;/em&gt; — each user has 2 orders, so each user gets fetched twice. Prisma doesn't deduplicate those calls. It doesn't have an identity map or a request-level cache. So you're not just making too many queries — you're making redundant ones too.&lt;/p&gt;
&lt;p&gt;The eager loading version sidesteps all of that. Prisma resolves the relationships from the batch result set in memory.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tc4-conditional-loading-the-tricky-one&quot;&gt;TC4: Conditional loading — the tricky one&lt;/h3&gt;
&lt;p&gt;Here's where things get interesting from a design perspective. You've got active orders, but you only need user data for orders where &lt;code&gt;requiresUserData&lt;/code&gt; is true. Not all of them.&lt;/p&gt;
&lt;p&gt;You can't just slap &lt;code&gt;include: { user: true }&lt;/code&gt; on this one — that would over-fetch user data for orders that don't need it. So the fix is different: collect the user IDs you actually need, fetch them all in one batch, build a lookup map, and assign the results.&lt;/p&gt;
&lt;p&gt;The bad version loops through active orders and calls &lt;code&gt;findUnique&lt;/code&gt; for each one that needs user data. About 70% of them do, which gave us 56 queries.&lt;/p&gt;
&lt;p&gt;The good version collects the needed IDs into a Set, does one &lt;code&gt;findMany&lt;/code&gt; with &lt;code&gt;WHERE id IN (...)&lt;/code&gt;, builds a Map, and does the assignment in a second pass. Two queries total.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Bad (N+1) &lt;/th&gt;&lt;th&gt;Good (Batch) &lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Queries&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;56 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;96.4% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Avg time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;22.75 ms &lt;/td&gt;&lt;td&gt;2.16 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.5x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Median&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;23.05 ms &lt;/td&gt;&lt;td&gt;2.11 ms &lt;/td&gt;&lt;td&gt;10.9x faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;P95&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;23.49 ms &lt;/td&gt;&lt;td&gt;2.57 ms &lt;/td&gt;&lt;td&gt;9.1x faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even though the conditional logic cuts the query count from a potential 200+ down to 56, you're still paying a &lt;strong&gt;10.5x penalty&lt;/strong&gt;. Partial N+1 is still N+1.&lt;/p&gt;
&lt;p&gt;This batch-query pattern is worth keeping in your back pocket. It works everywhere — not just in Prisma, not just in JavaScript. Collect the IDs, batch fetch, build a map, assign. You trade a bit of memory for the Map and an extra loop, but you eliminate dozens (or hundreds or thousands) of database round-trips. That trade-off pays for itself every time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-full-picture&quot;&gt;The full picture&lt;/h3&gt;
&lt;p&gt;Here's everything in one table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test Case &lt;/th&gt;&lt;th&gt;Bad Queries &lt;/th&gt;&lt;th&gt;Good Queries &lt;/th&gt;&lt;th&gt;Reduction &lt;/th&gt;&lt;th&gt;Bad Time &lt;/th&gt;&lt;th&gt;Good Time &lt;/th&gt;&lt;th&gt;Speedup&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1: One-to-many &lt;/td&gt;&lt;td&gt;101 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;98.0% &lt;/td&gt;&lt;td&gt;39.31 ms &lt;/td&gt;&lt;td&gt;4.28 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9.2x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2: Nested (3-level) &lt;/td&gt;&lt;td&gt;401 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;99.3% &lt;/td&gt;&lt;td&gt;153.59 ms &lt;/td&gt;&lt;td&gt;10.50 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.6x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3: Many-to-one &lt;/td&gt;&lt;td&gt;201 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;99.0% &lt;/td&gt;&lt;td&gt;82.54 ms &lt;/td&gt;&lt;td&gt;3.72 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;22.2x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4: Conditional &lt;/td&gt;&lt;td&gt;56 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;96.4% &lt;/td&gt;&lt;td&gt;22.75 ms &lt;/td&gt;&lt;td&gt;2.16 ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.5x&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few things jump out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The optimised versions never exceed 3 queries.&lt;/strong&gt; Doesn't matter which pattern, doesn't matter how many records. That's the whole point — you go from O(N) queries to O(1). And the benefit scales with your data. At 1,000 users, the bad TC2 version would fire over 4,000 queries. The good version would still fire 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The speedup correlates with query count.&lt;/strong&gt; TC3 had the most queries (201) and the biggest speedup (22.2x). TC4 had the fewest (56) and the smallest speedup (10.5x). Per-query overhead is roughly constant, so more queries = more waste = more gain from fixing it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;These numbers are the floor, not the ceiling.&lt;/strong&gt; I ran everything on localhost. In production, add network latency between app and database — maybe 1-5ms per round-trip. Now multiply that by 401 queries. That's where a 150ms endpoint becomes a 2-second one.&lt;/p&gt;
&lt;h3 id=&quot;what-happens-at-1-000-users-&quot;&gt;What happens at 1,000 users?&lt;/h3&gt;
&lt;p&gt;I ran the same benchmarks on a 10x larger dataset. 1,000 users, 3,000 posts, 6,000 comments, 2,000 orders.&lt;/p&gt;
&lt;p&gt;Here's the medium dataset alongside the small one:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test Case &lt;/th&gt;&lt;th&gt;100 Users &lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;1,000 Users &lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;TC1: One-to-many &lt;/td&gt;&lt;td&gt;39ms → 4ms &lt;/td&gt;&lt;td&gt;9.2x &lt;/td&gt;&lt;td&gt;364ms → 35ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.4x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Slightly worse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC2: Nested &lt;/td&gt;&lt;td&gt;154ms → 11ms &lt;/td&gt;&lt;td&gt;14.6x &lt;/td&gt;&lt;td&gt;1,271ms → 88ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;14.5x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;About the same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC3: Many-to-one &lt;/td&gt;&lt;td&gt;83ms → 4ms &lt;/td&gt;&lt;td&gt;22.2x &lt;/td&gt;&lt;td&gt;739ms → 28ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.5x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Noticeably worse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TC4: Conditional &lt;/td&gt;&lt;td&gt;23ms → 2ms &lt;/td&gt;&lt;td&gt;10.5x &lt;/td&gt;&lt;td&gt;169ms → 8ms &lt;/td&gt;&lt;td&gt;&lt;strong&gt;21.9x&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;Way&lt;/em&gt; worse&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few things I didn't expect.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC2 blew past 1 second.&lt;/strong&gt; 1,271ms for a single data fetch. With 4,001 queries. On localhost. In production with a network hop, you're looking at multiple seconds — from one API call. That's not a performance issue. That's a broken endpoint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC4's speedup doubled.&lt;/strong&gt; It went from 10.5x to 21.9x. That surprised me. But the reason makes sense once you look at the numbers: the batch query version barely grew (2ms → 8ms, only 3.6x) while the loop-with-findUnique version grew 7.4x. The batch approach has near-constant overhead regardless of how many IDs you're fetching — PostgreSQL is really good at &lt;code&gt;WHERE id IN (...)&lt;/code&gt; queries. The loop approach pays full overhead for every single query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TC3 got worse too.&lt;/strong&gt; 22.2x → 26.5x. Same story — the good version scales better than the bad version because batch fetching is fundamentally more efficient than doing it one at a time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The bad code scales roughly linearly.&lt;/strong&gt; 10x more data, roughly 8-9x more time across the board. Which makes sense — 10x more users means ~10x more queries in the loop, and per-query overhead is approximately constant.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The good code sometimes scales &lt;em&gt;better&lt;/em&gt; than linearly.&lt;/strong&gt; TC4's good version grew only 3.6x for 10x more data. The batch query with deduplication is doing less redundant work at scale.&lt;/p&gt;
&lt;p&gt;The bottom line: the N+1 penalty doesn't shrink as you scale. It gets worse. Every test case either maintained or increased its speedup factor at 1,000 users compared to 100. If you've got N+1 patterns in your code and you're thinking &quot;I'll fix it when we scale&quot; — the data says you're already paying for it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-to-spot-this-in-your-own-code&quot;&gt;How to spot this in your own code&lt;/h3&gt;
&lt;p&gt;The pattern is always the same: &lt;strong&gt;a database call inside a loop.&lt;/strong&gt; If you see &lt;code&gt;await prisma.something.findMany()&lt;/code&gt; or &lt;code&gt;findUnique()&lt;/code&gt; inside a &lt;code&gt;for&lt;/code&gt;, &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;forEach&lt;/code&gt;, or any kind of iteration — that's your red flag.&lt;/p&gt;
&lt;p&gt;Three ways to catch it:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Turn on query logging.&lt;/strong&gt; Set &lt;code&gt;log: [{ emit: &quot;stdout&quot;, level: &quot;query&quot; }]&lt;/code&gt; in your Prisma client config. If you see the same &lt;code&gt;SELECT&lt;/code&gt; statement repeated 50 times in a row, differing only in the &lt;code&gt;WHERE&lt;/code&gt; clause value, you've got an N+1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use static analysis.&lt;/strong&gt; Tools like &lt;a rel=&quot;nofollow&quot; href=&quot;https://codeevolutionlab.com&quot;&gt;Code Evolution Lab&lt;/a&gt; walk your AST (Abstract Syntax Tree) to find database calls nested inside loops. They can catch these before your code even runs — during CI, code review, or in your IDE. They'll also suggest the right fix for your specific ORM.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watch your APM dashboards.&lt;/strong&gt; If an endpoint's query count scales linearly with the number of records it returns, something is wrong. A healthy endpoint has a roughly constant query count.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;two-fixes-that-cover-almost-everything&quot;&gt;Two fixes that cover almost everything&lt;/h3&gt;
&lt;p&gt;Across all four test cases, two patterns handled every situation:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eager loading&lt;/strong&gt; for cases where you know you'll need the related data. In Prisma, that's &lt;code&gt;include&lt;/code&gt;. It works for one-to-many, many-to-one, and nested relationships. You can stack &lt;code&gt;include&lt;/code&gt; directives as deep as your schema goes, and Prisma always resolves it in a constant number of queries (one per entity level).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batch pre-fetching&lt;/strong&gt; for cases where you need related data conditionally. Collect the IDs, do one &lt;code&gt;findMany&lt;/code&gt; with &lt;code&gt;WHERE IN&lt;/code&gt;, build a Map for O(1) lookup, and do the assignment in memory. It's a few extra lines of code, but it turns 56 queries into 2.&lt;/p&gt;
&lt;p&gt;Both approaches do the same fundamental thing: they take the number of database round-trips from &quot;proportional to your data&quot; down to &quot;constant, no matter how much data.&quot; That's the whole game.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-d-push-back-on&quot;&gt;What I'd push back on&lt;/h3&gt;
&lt;p&gt;There's a common belief that N+1 queries are a &quot;scaling problem&quot; — something you can ignore early and fix when your dataset gets big enough to matter.&lt;/p&gt;
&lt;p&gt;The data doesn't support that.&lt;/p&gt;
&lt;p&gt;At 100 users, we're already seeing 9x-22x slowdowns. At 1,000 users, it's 10x-27x. That's not a rounding error. If your API has a 200ms budget per endpoint (pretty standard), the bad TC2 version is already eating 75% of that &lt;em&gt;with just 100 records and zero network latency&lt;/em&gt;. In production, you're probably blown past it.&lt;/p&gt;
&lt;p&gt;The other thing I'd push back on: the idea that &lt;code&gt;findUnique&lt;/code&gt; is somehow safe because it's a primary key lookup. It's fast per query, sure. But when you call it 200 times in a loop (TC3), the fixed overhead of 200 round-trips completely dominates. A fast query executed 200 times is slower than a slightly heavier query executed once.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-the-honesty-section-&quot;&gt;What about the honesty section?&lt;/h3&gt;
&lt;p&gt;A few things that could affect how you read these numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Localhost flatters the good version.&lt;/strong&gt; With no network latency, the per-query overhead is as small as it can be. In production, the penalty for each extra query would be higher, which means the speedup numbers would be &lt;em&gt;larger&lt;/em&gt; than what I measured. So think of these as conservative estimates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I only tested 100 users.&lt;/strong&gt; The scaling behaviour at 1K, 10K, and 100K records is something I want to measure next. I expect the gap to widen, not narrow, because connection pool contention and buffer pressure get worse with more queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This is Prisma-specific.&lt;/strong&gt; Sequelize, TypeORM, and other ORMs have different internals. The per-query overhead might be higher or lower. The fix patterns are similar though — eager loading and batching work everywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Warm caches.&lt;/strong&gt; I did a warmup run before measuring, which means PostgreSQL's query plan cache and shared buffers are warm. Both versions benefit equally from this, so it shouldn't bias the comparison. But cold-start numbers might look different.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;Everything's open source:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/liangk/empirical-study.git
cd empirical-study
npm install
npx prisma db push
npm run seed -- small
npm run bench:all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;small&lt;/code&gt; for &lt;code&gt;medium&lt;/code&gt; (1,000 users), &lt;code&gt;large&lt;/code&gt; (10,000), or &lt;code&gt;xlarge&lt;/code&gt; (100,000) to see how the numbers scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-coming-next&quot;&gt;What's coming next&lt;/h3&gt;
&lt;p&gt;This is the first piece in a series I'm working on. The questions I still want to answer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How do the numbers scale beyond 1K?&lt;/strong&gt; The 1,000-user results are in (see above) and the penalties got worse, not better. Next up: 10K and 100K, where I expect connection pool contention to make things even uglier.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can automated tools fix this?&lt;/strong&gt; I'm going to feed the bad code into &lt;a rel=&quot;nofollow&quot; href=&quot;https://codeevolutionlab.com&quot;&gt;Code Evolution Lab&lt;/a&gt; and see if the auto-generated solutions perform as well as the hand-written ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is Prisma better or worse than other ORMs?&lt;/strong&gt; Same benchmarks, Sequelize and TypeORM. The per-query overhead profile might be very different.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What about concurrent load?&lt;/strong&gt; All my benchmarks are single-threaded. Under real traffic, connection pool contention makes the N+1 penalty even worse.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The bottom line: the N+1 problem isn't a theoretical concern you can defer. It's a measurable, order-of-magnitude performance hit that shows up at 100 records and gets worse at 1,000. The nested pattern crossed 1.2 seconds. The conditional pattern's penalty doubled. And fixing it is rarely more than a one-line change.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The benchmark suite and raw data are on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/liangk/empirical-study&quot;&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://stackinsight.dev/blog/n-plus-1-query-empirical-study&quot;&gt;Stack Insight&lt;/a&gt;. For the best reading experience and future updates, visit the original post.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11471/we-measured-the-n-1-query-problem-the-numbers-are-worse-than-you-think</guid>
<pubDate>Thu, 12 Feb 2026 00:58:11 +0000</pubDate>
</item>
</channel>
</rss>