<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amujo Elijah</title>
    <description>The latest articles on DEV Community by Amujo Elijah (@amujo_elijah_333ac2135b66).</description>
    <link>https://dev.to/amujo_elijah_333ac2135b66</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1590406%2Fd57fa8d8-968b-4274-90d6-5e19733f6192.png</url>
      <title>DEV Community: Amujo Elijah</title>
      <link>https://dev.to/amujo_elijah_333ac2135b66</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amujo_elijah_333ac2135b66"/>
    <language>en</language>
    <item>
      <title>Django ORM &amp; PostgreSQL Indexing: Benchmarking B-Tree, Composite, and GIN Indexes</title>
      <dc:creator>Amujo Elijah</dc:creator>
      <pubDate>Tue, 29 Sep 2026 10:44:43 +0000</pubDate>
      <link>https://dev.to/amujo_elijah_333ac2135b66/django-orm-postgresql-indexing-benchmarking-b-tree-composite-and-gin-indexes-2kf</link>
      <guid>https://dev.to/amujo_elijah_333ac2135b66/django-orm-postgresql-indexing-benchmarking-b-tree-composite-and-gin-indexes-2kf</guid>
      <description>&lt;p&gt;When building data-intensive applications with Django, the Object-Relational Mapper (ORM) abstracts raw SQL queries seamlessly. However, as tables grow into tens or hundreds of thousands of rows, naive ORM queries can quietly turn into severe application bottlenecks.&lt;/p&gt;

&lt;p&gt;To understand how PostgreSQL handles different index types under realistic loads, I built a benchmark suite comparing execution plans on a dataset of &lt;strong&gt;50,000 records per table (100,000 total rows)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Using &lt;strong&gt;Django 6.1.1&lt;/strong&gt;, &lt;strong&gt;PostgreSQL 18&lt;/strong&gt;, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, this deep dive explores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single-column B-Tree indexes&lt;/li&gt;
&lt;li&gt;Composite indexes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JSONB&lt;/code&gt; GIN indexes&lt;/li&gt;
&lt;li&gt;PostgreSQL execution plans&lt;/li&gt;
&lt;li&gt;Query selectivity&lt;/li&gt;
&lt;li&gt;A few counter-intuitive cases where adding an index can actually make a query slower&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Environment &amp;amp; Database Schema
&lt;/h2&gt;

&lt;p&gt;The benchmark compares two identical tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UnindexedOrder&lt;/code&gt; — no database indexes beyond the defaults&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IndexedOrder&lt;/code&gt; — equipped with B-Tree, composite, and GIN indexes
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# orders/models.py
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.postgres.indexes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GinIndex&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UnindexedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EmailField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EmailField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="c1"&gt;# Standard B-Tree index for single-column equality
&lt;/span&gt;            &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idx_customer_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;

            &lt;span class="c1"&gt;# Composite index matching filter + ordering direction
&lt;/span&gt;            &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idx_status_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;

            &lt;span class="c1"&gt;# GIN index for JSONB metadata lookups
&lt;/span&gt;            &lt;span class="nc"&gt;GinIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idx_order_metadata_gin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both models were populated with identical datasets containing &lt;strong&gt;50,000 records each&lt;/strong&gt; using &lt;code&gt;bulk_create()&lt;/code&gt; to mimic real transactional order history.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Benchmark Performance Breakdown
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test Scenario&lt;/th&gt;
&lt;th&gt;Query Filter&lt;/th&gt;
&lt;th&gt;Unindexed Plan&lt;/th&gt;
&lt;th&gt;Indexed Plan&lt;/th&gt;
&lt;th&gt;Unindexed Time&lt;/th&gt;
&lt;th&gt;Indexed Time&lt;/th&gt;
&lt;th&gt;Performance Shift&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B-Tree Index&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single-column lookup&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bitmap Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81.73 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.69 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~30× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Composite Index&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Filter + Sort&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Seq Scan&lt;/code&gt; + &lt;code&gt;Sort&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.00 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.76 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~70× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN (Low Selectivity)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONB match (33.5% of dataset)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bitmap Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79.36 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;109.37 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~37% slower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN (High Selectivity)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONB match (11.1% of dataset)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bitmap Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132.93 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.41 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~27% faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; These results are specific to this benchmark environment and dataset. PostgreSQL's planner may choose a different execution strategy depending on table size, hardware, cache state, data distribution, statistics, and query shape.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. Execution Plan Analysis &amp;amp; Key Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Benchmark 1: Single-Column B-Tree Index (~30× Speedup)
&lt;/h3&gt;

&lt;p&gt;Querying by email address on an unindexed table forces PostgreSQL to perform a &lt;strong&gt;Sequential Scan (&lt;code&gt;Seq Scan&lt;/code&gt;)&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Unindexed
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on orders_unindexedorder
  (cost=0.00..1470.00 rows=8 width=98)
  Filter: ((customer_email)::text = 'user_42@gmail.com'::text)
  Rows Removed by Filter: 49993
  Buffers: shared hit=845

Planning Time: 7.347 ms
Execution Time: 81.731 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database had to examine all &lt;strong&gt;50,000 rows&lt;/strong&gt; across &lt;strong&gt;845 shared buffers&lt;/strong&gt; just to return 7 matching records.&lt;/p&gt;

&lt;p&gt;With a standard B-Tree index on &lt;code&gt;customer_email&lt;/code&gt;, PostgreSQL can use the index to locate candidate rows much more efficiently.&lt;/p&gt;

&lt;h4&gt;
  
  
  Indexed
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bitmap Heap Scan on orders_indexedorder
  (cost=4.35..34.12 rows=8 width=98)
  Recheck Cond: ((customer_email)::text = 'user_42@gmail.com'::text)
  Buffers: shared hit=9

  -&amp;gt; Bitmap Index Scan on idx_customer_email
     (cost=0.00..4.35 rows=8 width=0)

Execution Time: 2.693 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shared buffer hits dropped from &lt;strong&gt;845 to 9 blocks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's a dramatic reduction in the amount of table data PostgreSQL needed to touch. Conceptually, instead of walking through the entire table, PostgreSQL can use the B-Tree structure to locate matching values efficiently.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Benchmark 2: Composite Index &amp;amp; Eliminating In-Memory Sorts (~70× Speedup)
&lt;/h2&gt;

&lt;p&gt;A common query pattern in web application dashboards involves filtering by status while sorting by creation timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an appropriate index, PostgreSQL must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scan the table&lt;/li&gt;
&lt;li&gt;Find rows matching &lt;code&gt;status="completed"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sort the matching rows by &lt;code&gt;created_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Return the first 10 rows&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Unindexed
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit
  (cost=1738.18..1738.20 rows=10 width=98)
  Buffers: shared hit=848

  -&amp;gt; Sort
     (cost=1738.18..1769.20 rows=12410 width=98)
     Sort Key: created_at DESC
     Sort Method: top-N heapsort
     Memory: 28kB

     -&amp;gt; Seq Scan on orders_unindexedorder

Execution Time: 53.004 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is &lt;code&gt;Sort Method: top-N heapsort&lt;/code&gt;. PostgreSQL has to perform a sort because the table itself isn't organized in the order required by the query.&lt;/p&gt;

&lt;h4&gt;
  
  
  Indexed
&lt;/h4&gt;

&lt;p&gt;The composite index is defined as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idx_status_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting plan is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit
  (cost=0.41..3.53 rows=10 width=98)
  Buffers: shared hit=5

  -&amp;gt; Index Scan using idx_status_created on orders_indexedorder

Execution Time: 0.759 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composite index is ordered by &lt;code&gt;status&lt;/code&gt; then &lt;code&gt;created_at DESC&lt;/code&gt;. This matches the query's filtering and ordering requirements.&lt;/p&gt;

&lt;p&gt;As a result, PostgreSQL can walk the relevant section of the index, retrieve the first 10 matching rows, and stop. There is no separate sort step. Buffer access also dropped from &lt;strong&gt;848 to 5 shared hits&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Benchmark 3: GIN Indexes, Django Syntax Traps &amp;amp; the Selectivity Paradox
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;JSONB&lt;/code&gt; indexes behave differently from ordinary B-Tree indexes, and this is where things get particularly interesting.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 The Django ORM Syntax Trap
&lt;/h3&gt;

&lt;p&gt;Suppose we want to query the &lt;code&gt;metadata&lt;/code&gt; JSON field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metadata__plan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks perfectly reasonable from a Django perspective. However, the generated SQL uses JSON key extraction rather than the JSONB containment operator that a standard GIN index is designed to accelerate.&lt;/p&gt;

&lt;p&gt;For a default PostgreSQL &lt;code&gt;jsonb_ops&lt;/code&gt; GIN index, containment queries are a much better match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metadata__contains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a PostgreSQL JSONB containment condition using &lt;code&gt;@&amp;gt;&lt;/code&gt;, which can make use of the GIN index.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key point:&lt;/strong&gt; Creating a GIN index doesn't automatically mean every JSON lookup will use it. The operator used by the query matters.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. The Selectivity Paradox
&lt;/h2&gt;

&lt;p&gt;Here's where the benchmark produced a counter-intuitive result.&lt;/p&gt;

&lt;p&gt;For this query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metadata__contains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition matched &lt;strong&gt;16,752 out of 50,000 rows&lt;/strong&gt; (~&lt;strong&gt;33.5% of the table&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;The benchmark produced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Execution Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sequential Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79.36 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN Index&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;109.37 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The indexed query was approximately &lt;strong&gt;37% slower&lt;/strong&gt;. So why would adding an index make the query slower?&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 Indexes Aren't Always Faster
&lt;/h3&gt;

&lt;p&gt;An index has overhead. For a GIN-backed query, PostgreSQL may need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search the GIN index&lt;/li&gt;
&lt;li&gt;Build a bitmap of matching row locations&lt;/li&gt;
&lt;li&gt;Visit the corresponding heap pages&lt;/li&gt;
&lt;li&gt;Recheck the condition against the actual rows&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a query matches a large percentage of the table, this additional work can become more expensive than simply reading the table sequentially.&lt;/p&gt;

&lt;p&gt;In this case, PostgreSQL determined that a sequential scan was cheaper for the unindexed query.&lt;/p&gt;

&lt;p&gt;This is an important database principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An index is a tool for reducing unnecessary work—not a guarantee that less work will always happen.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  7. Reversing the Curve with Higher Selectivity
&lt;/h2&gt;

&lt;p&gt;Now consider a more selective query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metadata__contains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The additional condition reduced the matching dataset from &lt;strong&gt;16,752 rows&lt;/strong&gt; down to &lt;strong&gt;5,557 rows&lt;/strong&gt; (~&lt;strong&gt;11.1% of the table&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;The execution times changed significantly.&lt;/p&gt;

&lt;h4&gt;
  
  
  Unindexed
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on orders_unindexedorder
  Filter: (
    metadata @&amp;gt; '{"plan": "enterprise", "region": "EU"}'::jsonb
  )

Execution Time: 132.933 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Indexed
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bitmap Heap Scan on orders_indexedorder
  Recheck Cond: (
    metadata @&amp;gt; '{"plan": "enterprise", "region": "EU"}'::jsonb
  )

Execution Time: 96.408 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the GIN index wins:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Execution Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sequential Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132.93 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN Index&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.41 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's approximately a &lt;strong&gt;27% improvement&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Why Did Selectivity Change the Result?
&lt;/h2&gt;

&lt;p&gt;The important variable is the percentage of rows that match the query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low Selectivity
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;50,000 total rows&lt;/code&gt; -&amp;gt; &lt;code&gt;16,752 matches&lt;/code&gt; -&amp;gt; &lt;code&gt;33.5%&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The query is asking for a large portion of the table. In this situation, PostgreSQL may decide that scanning the table sequentially is cheaper than &lt;code&gt;GIN Index&lt;/code&gt; -&amp;gt; &lt;code&gt;Bitmap&lt;/code&gt; -&amp;gt; &lt;code&gt;Heap pages&lt;/code&gt; -&amp;gt; &lt;code&gt;Recheck rows&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Higher Selectivity
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;50,000 total rows&lt;/code&gt; -&amp;gt; &lt;code&gt;5,557 matches&lt;/code&gt; -&amp;gt; &lt;code&gt;11.1%&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now the index can eliminate much more unnecessary table access. The cost of using the index is outweighed by the amount of data PostgreSQL avoids scanning.&lt;/p&gt;

&lt;p&gt;This is why &lt;strong&gt;selectivity matters when designing and evaluating indexes&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Practical Takeaways for Django Developers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  9.1 Always Verify with &lt;code&gt;.explain()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Never assume an index is being used simply because you've created one. Django allows you to inspect the generated execution plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;queryset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IndexedOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer_email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_42@gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For deeper analysis, you can inspect PostgreSQL's execution plan using &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; where appropriate. Look for operations such as &lt;code&gt;Seq Scan&lt;/code&gt;, &lt;code&gt;Index Scan&lt;/code&gt;, &lt;code&gt;Bitmap Index Scan&lt;/code&gt;, &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;, and &lt;code&gt;Sort&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  9.2 Column Order Matters in Composite Indexes
&lt;/h3&gt;

&lt;p&gt;For queries like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;an index such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idx_status_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can allow PostgreSQL to combine filtering and ordering into a single index traversal.&lt;/p&gt;

&lt;p&gt;The order of fields in a composite index matters. Don't blindly create &lt;code&gt;["created_at", "status"]&lt;/code&gt; when your dominant query pattern is &lt;code&gt;WHERE status = ... ORDER BY created_at DESC&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  9.3 Use Appropriate Operators for JSONB GIN Indexes
&lt;/h3&gt;

&lt;p&gt;If you're using a standard PostgreSQL GIN index on a &lt;code&gt;JSONB&lt;/code&gt; column, containment queries are an important use case:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
Order.objects.filter(
    metadata__contains={"plan": "enterprise"}
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>django</category>
      <category>postgres</category>
      <category>database</category>
      <category>python</category>
    </item>
  </channel>
</rss>
