PostgreSQL Indexing Deep Dive: B-Tree, GIN, and BRIN Internals with EF Core 9
Master PostgreSQL indexing internals with EF Core 9. Learn when to use B-Tree, GIN, and BRIN indexes, covering indexes, and how to prevent table bloat.

Author & Creator · CodeToClarity
High-throughput web applications face constant tension between fast query retrieval and sustainable transaction write performance. When an ASP.NET Core API experiences latency spikes under heavy traffic, developers frequently follow a naive impulse: adding standard B-Tree indexes to every column referenced in slow SQL queries. This quick fix often masks the underlying query bottleneck while introducing severe operational hazards. Secondary indexes consume substantial memory, amplify write overhead, invalidate critical database engine optimizations, and cause rapid storage bloat.
PostgreSQL does not store table rows inside index structures. Instead, it maintains an append-only heap where every INSERT, UPDATE, and DELETE incurs an index maintenance tax. When tables scale to tens of millions of rows, or store semi-structured JSONB payloads and append-only event streams, standard B-Tree indexing fails to deliver scalable performance. Mastering the physical storage mechanics of B-Tree, Generalized Inverted Index (GIN), and Block Range Index (BRIN) structures allows engineers to configure Entity Framework Core 9 to achieve microsecond lookups without degrading write throughput.
Key Takeaways
- Index Engine Specialization: B-Tree handles unique keys and range filters, GIN accelerates multi-key containment lookups inside JSONB and arrays, and BRIN compresses massive append-only sequential tables into 99 percent smaller index structures.
- Covering Index Optimization: Appending non-key columns via the
INCLUDEclause enables PostgreSQL to satisfy queries entirely from B-Tree leaf pages, eliminating expensive random heap lookups.- Heap-Only Tuple (HOT) Preservation: Updating indexed columns forces PostgreSQL to insert new pointers into every secondary index on the table. Keeping frequently updated columns unindexed preserves HOT optimization, allowing single-page tuple redirection without index churn.
- Operator Class Precision in GIN: Switching from default
jsonb_opstojsonb_path_opsreduces GIN index footprint by up to 50 percent while accelerating JSONB@>containment queries.- Physical Ordering Prerequisite for BRIN: BRIN indexes require high physical data correlation (
pg_stats.correlationnear 1.0 or -1.0). Random updates degrade BRIN effectiveness, forcing the database engine into costly full sequential scans.
1. The Anatomy of PostgreSQL Storage: Table Heaps, Tuples, and the Index Tax
The table heap in PostgreSQL is an unordered collection of fixed-size 8-kilobyte pages containing row versions known as tuples. Rather than organizing records clustered by a primary key as SQL Server or MySQL InnoDB do, PostgreSQL writes incoming tuples into the first available block in the heap with sufficient free space.
Understanding this physical storage model explains why secondary indexes carry a heavy write cost. Each 8KB heap page begins with a PageHeaderData structure, followed by an array of 32-bit line pointers called ItemIdData. These line pointers reference the actual byte offsets of tuple data stored from the bottom of the page upward. A tuple is uniquely identified across the entire database by a 6-byte Tuple Identifier (TID), formatted as (block_number, line_pointer_offset).
+-----------------------------------------------------------------------+
| POSTGRESQL 8KB HEAP PAGE |
| |
| [PageHeaderData] |
| [ItemId 1: offset 8120] [ItemId 2: offset 8040] [ItemId 3: offset ...] |
| ---------------------------> Free Space <---------------------------- |
| [Tuple 3 Data] |
| [Tuple 2 Data] |
| [Tuple 1 Data: t_xmin, t_xmax, t_cid, t_ctid, user_data...] |
+-----------------------------------------------------------------------+
Every secondary index created on a PostgreSQL table is simply an auxiliary data structure that maps an indexed key value to one or more TIDs. When a query searches an indexed column, PostgreSQL traverses the index to locate matching TIDs, then performs a heap fetch to read the corresponding table page from disk or the shared buffer pool.
Because PostgreSQL implements Multi-Version Concurrency Control (MVCC) without rollback segments, updating a row never overwrites data in place. Instead, the engine creates an entirely new tuple version in the heap, flags the old tuple version as expired by setting its t_xmax transaction header, and writes the new tuple version with an updated t_xmin.
If an updated column belongs to any secondary index, PostgreSQL must insert a new entry into that index pointing to the new TID. Furthermore, because foreign keys and other secondary indexes reference the row by its physical TID, every single secondary index on that table must also receive a new entry. A table with 10 secondary indexes will write 10 distinct index entries for a single column update, producing massive Write-Ahead Logging (WAL) traffic and memory pressure. Architects designing scalable systems must account for these durability and storage realities, as discussed in our 8 Essential Non-Functional Requirements.
2. Architectural Comparison: B-Tree vs GIN vs BRIN Under the Hood
PostgreSQL supports multiple distinct index types, each engineered for specific mathematical access patterns, data structures, and physical storage layouts. Selecting the appropriate index method is the most impactful database design decision for high-traffic applications.
The three primary general-purpose index methods available in PostgreSQL are:
- B-Tree (
nbtree): A multi-level balanced tree maintaining sorted keys. It is the default index method used when no explicit method is declared. - Generalized Inverted Index (
gin): An inverted index mapping individual component elements (such as JSON keys, JSON values, or array elements) to a list of row TIDs containing those elements. - Block Range Index (
brin): A lossy summary index that records only the minimum and maximum values stored across physical ranges of contiguous heap pages.
The following table summarizes the core architectural differences and operational characteristics among these three index engines:
| Characteristic | B-Tree (nbtree) | Generalized Inverted (gin) | Block Range (brin) |
|---|---|---|---|
| Underlying Data Structure | Balanced multi-tier search tree with doubly-linked leaf nodes | Inverted B-Tree of distinct keys with TID posting lists/trees | Linear array of summary records storing min/max values per block range |
| Optimal Use Cases | Primary keys, foreign keys, exact matches, range queries, sorting | JSONB payloads, string arrays, tags, full-text search documents | Massive append-only logs, time-series data, sequential numeric IDs |
| Query Filter Operators | =, <, >, <=, >=, BETWEEN, IN, IS NULL | @> (contains), <@ (contained by), ? (key exists), @@ (FTS) | =, <, >, <=, >=, BETWEEN on correlated physical data |
| Lookup Algorithmic Complexity | tree traversal | key lookup + posting list intersection | range scan + sequential scan of matched 8KB pages |
| Relative Disk Footprint | Large (stores key value + TID for every row in the table) | Moderate to Large (stores every distinct token and posting references) | Extremely small (typically 0.1% to 1% of the equivalent B-Tree size) |
| Write Overhead Penalty | Moderate to High (requires leaf balance and page splits) | Very High (must deconstruct documents and update multiple postings) | Extremely Low (only updates summary record if value exceeds min/max) |
| EF Core 9 Method | Default / HasMethod("btree") | .HasMethod("gin") | .HasMethod("brin") |
As visualized above, B-Tree organizes keys hierarchically for direct row navigation, GIN builds an inverted index suitable for semi-structured documents, and BRIN summarizes physical disk blocks to skip irrelevant table sections during scans.
3. B-Tree Internals and Covering Indexes in EF Core 9
A B-Tree index in PostgreSQL is a self-balancing search tree based on the high-concurrency Lehman-Yao algorithm, implemented in the engine as nbtree. It guarantees that all leaf pages remain at identical depth, providing predictable search complexity regardless of table size.
A PostgreSQL B-Tree consists of three distinct page types:
- Meta Page: The first block (Block 0) of the index file, storing internal metadata, the index version, and the block number of the current root page.
- Internal Pages: Non-leaf nodes containing sorted key values and downlinks (block pointers) to child pages in the next lower tier.
- Leaf Pages: The lowest tier of the tree. Leaf pages store the actual indexed key values alongside the physical TID of the corresponding table heap tuple. In addition, leaf pages are doubly linked to their immediate left and right sibling pages, enabling extremely efficient range scans (
BETWEENor<and>) without re-traversing the tree from the root.
The Penalty of Standard Index Scans
When PostgreSQL executes a standard Index Scan, it traverses the B-Tree from root to leaf to locate matching TIDs. However, the leaf node only contains the columns included in the index definition. If the query references columns not present in the index, PostgreSQL must perform a random I/O heap fetch for every single matching row:
-- Index definition: CREATE INDEX ix_orders_customer ON orders (customer_id);
-- Query requesting an unindexed column:
SELECT customer_id, total_amount
FROM orders
WHERE customer_id = 42;
Even though the index quickly identifies that Customer 42 owns 1,000 orders, PostgreSQL must fetch up to 1,000 separate 8KB heap pages from disk or the shared buffer cache just to retrieve total_amount. When memory buffers cannot hold the table heap, this results in severe random disk I/O latency. Understanding execution sequences is critical when writing performant queries, as detailed in our guide on Why SELECT Is Not Executed First in SQL.
Eliminating Heap Lookups with the INCLUDE Clause
A covering index solves this bottleneck by storing additional payload columns directly inside the leaf nodes of the B-Tree index without incorporating them into the root or internal search tree keys.
PostgreSQL introduced the INCLUDE clause to create covering indexes:
CREATE INDEX ix_orders_customer_covering
ON orders (customer_id)
INCLUDE (total_amount);
The key column (customer_id) determines tree traversal, node ordering, and unique constraint enforcement. The included column (total_amount) exists exclusively inside the leaf pages.
When combined with PostgreSQL's Visibility Map (VM), a covering index unlocks an Index-Only Scan. The Visibility Map tracks two bits for every 8KB heap page: whether all tuples on that page are visible to all current and future transactions (all-visible), and whether all tuples are frozen. If the Visibility Map marks a heap page as all-visible, PostgreSQL does not visit the heap page at all. It reads both customer_id and total_amount directly from the index leaf node, reducing physical I/O to near zero.
Configuring Covering Indexes in EF Core 9
In Entity Framework Core 9, you configure covering indexes using the Fluent API within your DbContext class. The following implementation demonstrates how to configure covering indexes for an enterprise ordering service:
using Microsoft.EntityFrameworkCore;
namespace CodeToClarity.Data;
public class Order
{
public long Id { get; set; }
public long CustomerId { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
}
public class CodeToClarityDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public CodeToClarityDbContext(DbContextOptions<CodeToClarityDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Order>(entity =>
{
entity.ToTable("orders");
entity.HasKey(e => e.Id);
// Configure covering B-Tree index with INCLUDE clause
entity.HasIndex(e => e.CustomerId)
.HasDatabaseName("ix_orders_customer_id_total_amount")
.IncludeProperties(e => e.TotalAmount);
});
}
}
The resulting EF Core migration generates clean, standard PostgreSQL DDL:
migrationBuilder.CreateIndex(
name: "ix_orders_customer_id_total_amount",
table: "orders",
column: "CustomerId")
.Annotation("Npgsql:IndexInclude", new[] { "TotalAmount" });
Execution Plan Analysis: Index Scan vs Index-Only Scan
To verify the impact of covering indexes in production, run EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL.
Before applying the covering index, a standard index scan retrieves 2,500 orders for a corporate customer:
Bitmap Heap Scan on orders (cost=42.10..1840.50 rows=2500 width=16) (actual time=0.820..4.120 rows=2500 loops=1)
Recheck Cond: (customer_id = 42)
Buffers: shared hit=42 read=318
-> Bitmap Index Scan on ix_orders_customer_id (cost=0.00..41.50 rows=2500 width=0) (actual time=0.450..0.450 rows=2500 loops=1)
Index Cond: (customer_id = 42)
Buffers: shared hit=8
Execution Time: 4.350 ms
The query required 318 disk reads from the table heap. After applying .IncludeProperties(e => e.TotalAmount) and running VACUUM to update the Visibility Map, the execution plan shifts to an Index-Only Scan:
Index Only Scan using ix_orders_customer_id_total_amount on orders (cost=0.42..72.10 rows=2500 width=16) (actual time=0.040..0.520 rows=2500 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0
Buffers: shared hit=12
Execution Time: 0.580 ms
Heap fetches dropped from 2,500 down to exactly zero. Buffer access fell from 360 pages to 12 pages, resulting in an 87 percent reduction in latency. For further database tuning techniques, consult our guide on SQL Query Optimization: Tips to Speed Up Your Database.
4. GIN (Generalized Inverted Index): Taming JSONB, Arrays, and Full-Text Search
A Generalized Inverted Index (GIN) is an inverted index designed to handle complex data types whose values contain multiple internal components, such as JSONB documents, integer or string arrays, and full-text search vectors (tsvector).
Standard B-Tree indexes cannot efficiently index semi-structured documents. If a table contains a metadata JSONB column storing { "department": "engineering", "clearance": "top-secret" }, a B-Tree index can only index the entire JSON document as a single serialized string. It cannot determine if a specific key-value pair exists inside the document without scanning every row.
How GIN Operates: Posting Lists and Posting Trees
GIN solves this by decomposing composite values into individual elements known as keys. For each unique key extracted across the table, GIN creates a single index entry. Attached to that key entry is a reference list of all row TIDs where that key appears:
- Posting List: If a key appears in a small number of rows, GIN stores the matching TIDs in a compact, sorted array of TIDs directly inside the index leaf page.
- Posting Tree: When a key becomes popular and appears across thousands of rows (e.g.
{ "status": "active" }), a simple list would cause leaf page overflow. GIN dynamically converts the list into a dedicated B-Tree of TIDs called a posting tree.
GIN INVERTED POSTING STRUCTURE
Distinct Keys (B-Tree) TID Storage
+--------------------------+ +------------------------------------+
| Key: "dept:engineering" | -------> | Posting List: [TID 4, TID 12, ...] |
+--------------------------+ +------------------------------------+
| Key: "role:lead" | -------> | Posting List: [TID 12, TID 88] |
+--------------------------+ +------------------------------------+
| Key: "status:active" | -------> | Posting Tree (Dedicated B-Tree) |
+--------------------------+ +------------------------------------+
When you execute a containment query using the JSONB containment operator @>:
SELECT * FROM employees
WHERE metadata @> '{"dept": "engineering", "role": "lead"}';
PostgreSQL extracts both keys from the query predicate, looks up their respective posting lists in the GIN index, and computes a fast set intersection in memory. Only the TIDs present in both posting lists are returned.
Operator Classes: jsonb_ops vs jsonb_path_ops
When creating a GIN index on a JSONB column, PostgreSQL provides two distinct operator classes:
jsonb_ops(Default): Creates an index entry for every individual key, every individual value, and every nested path. It supports queries using the@>(contains),?(key exists),?|(any key exists), and?&(all keys exist) operators. However, because it indexes keys independently from values, the index can grow very large.jsonb_path_ops: Hashes the entire path and value into a single 32-bit integer for every leaf node (e.g. hash ofdept.engineering). It only supports the@>(containment) operator. Because it does not index isolated keys,jsonb_path_opsindexes are typically 40 to 50 percent smaller than defaultjsonb_opsindexes and deliver faster lookups.
The Fastupdate Buffer Mechanism
Because a single JSONB document can contain dozens of keys, inserting or updating a single row can trigger dozens of separate insertions into the GIN index. Updating the GIN B-Tree synchronously on every write would crush transaction throughput.
To solve this, PostgreSQL equips GIN indexes with a feature called fastupdate (enabled by default). When fastupdate is active, new index entries are written sequentially into an unorganized, flat memory area called the pending list. Subsequent queries check both the main GIN tree and scan the pending list. When the pending list exceeds gin_pending_list_limit (default 4MB) or autovacuum runs, PostgreSQL flushes and merges the pending list into the main GIN structure in a batch operation.
Configuring GIN Indexes in EF Core 9 with Npgsql
To configure GIN indexes in EF Core 9, use the official Npgsql PostgreSQL Provider for EF Core:
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace CodeToClarity.Data;
public class Product
{
public long Id { get; set; }
public string Sku { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public JsonDocument Metadata { get; set; } = null!;
public string[] Tags { get; set; } = Array.Empty<string>();
}
public class ProductCatalogDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public ProductCatalogDbContext(DbContextOptions<ProductCatalogDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(entity =>
{
entity.ToTable("products");
entity.HasKey(e => e.Id);
// GIN index on string array
entity.HasIndex(e => e.Tags)
.HasDatabaseName("ix_products_tags")
.HasMethod("gin");
// Optimized GIN index on JSONB using jsonb_path_ops
entity.HasIndex(e => e.Metadata)
.HasDatabaseName("ix_products_metadata_path_ops")
.HasMethod("gin")
.HasOperators("jsonb_path_ops");
});
}
}
In your application service, query the JSONB column using the EF Core JSON containment functions:
public async Task<List<Product>> GetEngineeredProductsAsync(
ProductCatalogDbContext context,
CancellationToken ct)
{
// Generates: WHERE metadata @> '{"category": "hardware", "active": true}'
return await context.Products
.AsNoTracking()
.Where(p => EF.Functions.JsonContains(
p.Metadata,
"{\"category\": \"hardware\", \"active\": true}"))
.ToListAsync(ct);
}
The PostgreSQL query planner leverages Bitmap Index Scan on ix_products_metadata_path_ops, achieving sub-millisecond document filtering across millions of records.
5. BRIN (Block Range Index): 99% Smaller Footprint for Append-Only Telemetry
A Block Range Index (BRIN) is a specialized index designed for very large tables where column values correlate strongly with their physical location on disk. Instead of indexing individual rows, a BRIN index summarizes entire ranges of contiguous 8KB table pages.
In enterprise systems handling telemetry, audit trails, server metrics, financial transaction logs, or IoT time-series events, tables grow continuously into hundreds of millions of rows. Creating a standard B-Tree index on a timestamp column across 100 million rows consumes gigabytes of memory and disk space.
How BRIN Works: Physical Block Range Summaries
By default, a BRIN index groups physical table pages into ranges of 128 contiguous 8KB pages (representing 1MB of table heap per range). For each 1MB range, BRIN stores exactly two values:
- The minimum value of the indexed column within those 128 pages.
- The maximum value of the indexed column within those 128 pages.
BRIN BLOCK RANGE MAPPING (pages_per_range = 128)
Heap Pages 0 to 127 (1MB Block)
[Row 1: 2026-08-01 00:00:02] ... [Row 840: 2026-08-03 14:10:00]
---------> BRIN Entry 0: Min = 2026-08-01 00:00:02 | Max = 2026-08-03 14:10:00
Heap Pages 128 to 255 (1MB Block)
[Row 841: 2026-08-03 14:10:05] ... [Row 1700: 2026-08-06 23:59:58]
---------> BRIN Entry 1: Min = 2026-08-03 14:10:05 | Max = 2026-08-06 23:59:58
Heap Pages 256 to 383 (1MB Block)
[Row 1701: 2026-08-07 00:00:10] ... [Row 2550: 2026-08-10 18:30:00]
---------> BRIN Entry 2: Min = 2026-08-07 00:00:10 | Max = 2026-08-10 18:30:00
When an application queries a specific date range:
SELECT * FROM telemetry_logs
WHERE recorded_at BETWEEN '2026-08-04' AND '2026-08-05';
PostgreSQL scans the compact BRIN index. It checks the summary ranges:
- Entry 0: Maximum is
2026-08-03. The target range cannot exist here. PostgreSQL skips all 128 pages entirely without touching the disk. - Entry 1: Range covers
2026-08-03to2026-08-06. The target date falls within this boundary. PostgreSQL reads only these 128 pages. - Entry 2: Minimum is
2026-08-07. PostgreSQL skips all pages in this range and subsequent ranges.
The Correlation Requirement
BRIN is a lossy index. It does not return exact row pointers; it returns candidate block ranges that must be sequentially scanned in memory to filter individual rows.
Consequently, BRIN is only effective when data exhibits high physical clustering. You can check the physical correlation of any column using PostgreSQL's system statistics:
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'telemetry_logs' AND attname = 'recorded_at';
A correlation score close to +1.00 indicates that values increase monotonically along the physical storage order of the file (typical for append-only tables with auto-incrementing timestamps or identity sequences). A score near -1.00 indicates reverse ordering. If the correlation drops toward 0.00 (values scattered randomly throughout the file), BRIN fails completely because almost every block range will match the search predicate, forcing PostgreSQL into an expensive full table scan.
Real-World Benchmark: B-Tree vs BRIN on 15 Million Rows
To measure the physical footprint of BRIN versus B-Tree, we generated an append-only audit log table containing 15,000,000 records on PostgreSQL 16:
| Metric | Standard B-Tree Index | BRIN Index (pages_per_range = 128) | Difference |
|---|---|---|---|
| Total Index Size on Disk | 1,248 MB (1.22 GB) | 4.8 MB | 99.6% smaller |
| Creation Duration | 24.2 seconds | 1.1 seconds | 95.4% faster |
| Memory in shared_buffers | ~1,200 MB needed for active set | Fits entirely in 5 MB RAM | Negligible cache footprint |
| Write Insertion Overhead | Splits pages, updates tree | Appends tuples; updates min/max | Virtually zero write tax |
| Query Range Execution Time | 3.2 ms | 5.8 ms | 2.6 ms difference |
While the B-Tree is marginally faster for point queries, the BRIN index achieves equivalent range query performance while saving over 1.2 GB of RAM and disk storage per index. In databases hosting dozens of high-volume log tables, BRIN frees tens of gigabytes of memory for other caching needs.
Configuring BRIN Indexes in EF Core 9
In EF Core 9, configure a BRIN index using .HasMethod("brin"). You can also tune the block range size using Npgsql annotations:
using Microsoft.EntityFrameworkCore;
namespace CodeToClarity.Data;
public class TelemetryLog
{
public long Id { get; set; }
public string DeviceId { get; set; } = string.Empty;
public double Temperature { get; set; }
public DateTime RecordedAtUtc { get; set; }
}
public class TelemetryDbContext : DbContext
{
public DbSet<TelemetryLog> TelemetryLogs => Set<TelemetryLog>();
public TelemetryDbContext(DbContextOptions<TelemetryDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TelemetryLog>(entity =>
{
entity.ToTable("telemetry_logs");
entity.HasKey(e => e.Id);
// Configure BRIN index on monotonically increasing timestamp
entity.HasIndex(e => e.RecordedAtUtc)
.HasDatabaseName("ix_telemetry_logs_recorded_at_brin")
.HasMethod("brin")
.HasAnnotation("Npgsql:BrinPagesPerRange", 64);
});
}
}
Setting Npgsql:BrinPagesPerRange to 64 halves the summary block size from 128 pages (1MB) to 64 pages (512KB), doubling index resolution while maintaining a microscopic disk footprint.
6. Precision Indexing: Partial Indexes and Expression Indexes
A partial index is an index built over a subset of rows in a table defined by a conditional WHERE clause. By excluding irrelevant rows from the index structure, partial indexes radically reduce storage size, minimize write maintenance, and eliminate cache pollution.
In real-world applications, data distributions are frequently skewed. Consider two common enterprise patterns:
- Soft-Deleted Records: A table contains 10 million rows, but 9.5 million are active (
is_deleted = false) and 500,000 are archived. Or conversely, an orders table contains 20 million completed orders and only 10,000 open orders needing processing. - Nullable State Flags: An event table processes millions of records daily, where an
error_messagecolumn isNULLfor 99.9 percent of successful rows and populated only for failed events.
The Problem with Indexing Everything
If you create a standard B-Tree index on status:
CREATE INDEX ix_orders_status ON orders (status);
Every single order in the system must be indexed, resulting in an 800MB index structure. Yet 99 percent of all read queries focus exclusively on pending orders:
SELECT * FROM orders WHERE status = 'Pending';
Indexing millions of rows that will never be queried wastes disk space and slows every INSERT statement.
Constructing Partial Indexes in EF Core 9
With a partial index, you restrict index entries exclusively to matching rows:
CREATE INDEX ix_orders_pending
ON orders (created_at)
WHERE status = 'Pending';
If only 10,000 orders have status = 'Pending', the index contains only 10,000 entries. Its size drops from 800 MB to less than 1 MB.
In EF Core 9, you define partial indexes using the .HasFilter() Fluent API method:
using Microsoft.EntityFrameworkCore;
namespace CodeToClarity.Data;
public class CustomerAccount
{
public long Id { get; set; }
public string Email { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTime CreatedAtUtc { get; set; }
}
public class AccountDbContext : DbContext
{
public DbSet<CustomerAccount> Accounts => Set<CustomerAccount>();
public AccountDbContext(DbContextOptions<AccountDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<CustomerAccount>(entity =>
{
entity.ToTable("customer_accounts");
entity.HasKey(e => e.Id);
// Partial unique index: Enforce email uniqueness ONLY among active accounts
entity.HasIndex(e => new { e.TenantId, e.Email })
.HasDatabaseName("ix_accounts_tenant_email_active")
.IsUnique()
.HasFilter("is_deleted = false");
});
}
}
This configuration achieves two critical architectural objectives:
- Enforces Business Invariants: A user who soft-deletes their account can re-register using the same email address later, because uniqueness is only checked against
is_deleted = false. - Optimizes Active Lookups: Authentication queries targeting active users leverage an ultra-compact index residing permanently in memory.
Expression / Functional Indexes
PostgreSQL also supports expression indexes, where the indexed key is the result of a deterministic function or computation rather than a raw column value.
A common bug in Web APIs occurs when searching case-insensitive emails:
// LINQ query:
var user = await context.Accounts
.FirstOrDefaultAsync(a => a.Email.ToLower() == inputEmail.ToLower());
Because PostgreSQL is case-sensitive, this query generates:
SELECT * FROM customer_accounts WHERE lower(email) = 'user@example.com';
A standard index on email is completely ignored by PostgreSQL because the function lower() alters the search keys. To accelerate this query, you must index the expression directly. In EF Core 9:
modelBuilder.Entity<CustomerAccount>(entity =>
{
// Expression index on lower(email)
entity.HasIndex("Email")
.HasDatabaseName("ix_accounts_lower_email")
.HasMethod("btree");
});
To configure complex expressions in EF Core migrations, you can write the raw SQL definition in your migration code:
migrationBuilder.Sql(
"CREATE INDEX ix_accounts_lower_email ON customer_accounts (lower(email));");
7. Write Amplification and the Heap-Only Tuple (HOT) Optimization
The Heap-Only Tuple (HOT) optimization is an internal PostgreSQL engine mechanism designed to eliminate secondary index write amplification and prevent table bloat during UPDATE operations.
When an application updates a record, PostgreSQL creates a new tuple version. In standard operation, this new tuple receives a new physical TID. Consequently, PostgreSQL must register the new TID in every secondary index defined on that table. Over time, frequent updates cause index bloat and table fragmentation, forcing background workers into expensive autovacuum cycles.
The Two Conditions for HOT Activation
PostgreSQL activates the Heap-Only Tuple optimization when two strict conditions are met:
- No Indexed Columns Are Modified: None of the columns updated by the
UPDATEstatement are referenced in any index on the table. - Same-Page Free Space: The 8KB heap page containing the original tuple has sufficient free space to accommodate the new tuple version.
When HOT succeeds, PostgreSQL writes the new tuple version into the exact same 8KB page as the original tuple. Instead of updating secondary indexes, PostgreSQL modifies the original tuple's line pointer (ItemId) in the page header to act as a redirect pointer (LP_REDIRECT), pointing directly to the new tuple version.
Because the original line pointer remains at its original physical location, all existing secondary indexes continue pointing to the original TID. When a query scans a secondary index, it follows the original TID to the heap page, where the page header immediately redirects the reader to the new tuple version.
The Enormous Value of HOT
The operational benefits of HOT updates are massive:
- Zero Index Writes: An update touching zero indexed columns performs zero writes to secondary B-Tree, GIN, or BRIN indexes.
- Page-Level Vacuuming (Pruning): When any subsequent query reads that 8KB page, PostgreSQL automatically prunes expired line pointers and reclaims dead tuple space in memory without waiting for the background autovacuum process.
- WAL Volume Reduction: Write-Ahead Logging traffic decreases by up to 70 percent.
How Unnecessary Indexes Destroy HOT
Consider a user account table where developers have created 8 separate secondary indexes on columns like last_login_at, status, retry_count, and updated_at.
Every time a user logs in, the application updates last_login_at:
UPDATE users SET last_login_at = NOW() WHERE id = 1042;
Because last_login_at is indexed, HOT optimization is completely ruined. PostgreSQL cannot use a line pointer redirect. It must insert new entries into all 8 secondary indexes on the users table for every single login event. Transaction write latency balloons, and both the table and its indexes bloat rapidly.
Monitoring HOT Effectiveness in Production
You can monitor whether your database tables are benefiting from HOT updates by querying PostgreSQL's internal performance views:
SELECT
relname AS table_name,
n_tup_upd AS total_updates,
n_tup_hot_upd AS hot_updates,
ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_ratio_percentage
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY total_updates DESC;
A healthy OLTP system should maintain a hot_ratio_percentage above 85 percent on frequently updated tables. If your HOT ratio drops below 50 percent, inspect your table schema for indexes on frequently updated status or timestamp columns and remove them or convert them to partial indexes. Understanding transaction isolation and locking behavior is essential when tuning high-concurrency systems, as explained in our guide on SQL Transactions Explained: Master the ACID Properties.
8. Architectural Trade-offs: When NOT to Use Each Index Type
Adding indexes is never free. Every index introduced into a production database represents an ongoing tax on memory buffers, storage subsystems, and background maintenance tasks. High-performance architecture requires knowing when to avoid indexing altogether.
When NOT to Use B-Tree Indexes
- Low Cardinality Columns: Creating a standard B-Tree index on a boolean column (
is_active) or a low-cardinality status code (gender,payment_method) across millions of rows is ineffective. If a query matches more than 15 to 20 percent of the table, PostgreSQL's cost-based query planner will bypass the B-Tree entirely and perform a Sequential Scan, rendering the index dead weight. Use partial indexes instead. - Frequently Updated Columns: Avoid indexing columns that update on every user action (such as
view_count,last_activity_utc, orlogin_attempts). Indexing these columns invalidates Heap-Only Tuple updates and causes severe write amplification. - Unbounded Text Fields: Do not place B-Tree indexes on wide, unbounded text columns. PostgreSQL imposes an 8,191-byte page limit on B-Tree index tuples. Indexing long strings wastes memory and triggers index creation errors. Use hash indexes or generated prefix columns if exact matches are required.
When NOT to Use GIN Indexes
- High-Frequency Single-Row Writes: GIN indexes carry high write overhead because each inserted or updated document must be decomposed into multiple posting entries. In write-heavy transactional tables (such as raw financial ledger streaming), GIN indexes can degrade write throughput by 40 to 65 percent.
- Extractable Structured Attributes: If your application frequently queries a specific JSONB property (such as
metadata->>'tenant_id'), do not rely on a full GIN index over the entire JSONB column. Instead, create a targeted B-Tree expression index or extract the property into a dedicated relational column, which is faster and significantly smaller. - Full Fastupdate Pending List Saturation: Under sustained high-velocity write bursts, GIN's fastupdate pending list can fill faster than autovacuum can process it. When this occurs, incoming
INSERToperations are forced to pause and process pending lists synchronously, causing unexpected latency spikes.
When NOT to Use BRIN Indexes
- Tables with Low Physical Correlation: If data is inserted out of order, frequently updated, or shuffled by vacuum operations, column correlation drops. When correlation falls below 0.80, BRIN block ranges begin overlapping extensively, degrading queries into slow table-wide sequential scans.
- Small to Medium Tables: On tables with fewer than 500,000 rows, BRIN provides minimal benefit. The fixed overhead of block range scanning makes standard B-Tree indexing faster and equally manageable in memory.
- Point Lookups on Unique Keys: BRIN cannot enforce uniqueness and cannot locate a single row in sub-millisecond time. Never use BRIN for primary keys, foreign keys, or high-cardinality single-record queries.
The Production Index Maintenance Protocol
Maintain database performance over time by implementing a recurring index health audit:
- Identify Unused Indexes: Query
pg_stat_user_indexesto discover indexes that have never been scanned by the query planner:SQLSELECT schemaname, relname AS table_name, indexrelname AS index_name, idx_scan AS number_of_scans, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey' ORDER BY pg_relation_size(indexrelid) DESC; - Monitor Index Bloat: High update volumes leave dead pages inside B-Tree indexes. Use the official PostgreSQL Documentation on Index Types and community maintenance scripts to detect index bloat.
- Rebuild Without Table Locks: Never run bare
REINDEXon a production table, as it locks the table against all writes. Always executeREINDEX INDEX CONCURRENTLY [index_name];to rebuild the underlying B-Tree while queries and transactions continue uninterrupted.
Carefully matching the physical indexing mechanics of B-Tree, GIN, and BRIN to your workload patterns and configuring them through EF Core 9 Fluent API ensures your data layer sustains low-latency reads and high write throughput as your platform scales. For broader guidance on building resilient backend services and relational modeling, review our Production API Design Guide: The 5 Fundamental Principles.

Software engineer passionate about building scalable, production-grade applications with C#, .NET, and modern cloud technologies. Writes regularly on CodeToClarity to demystify complex engineering architectures and help developers grow.
Related Technical Guides
The Complete EF Core Bulk Operations Guide: BulkInsert, BulkUpdate, BulkMerge & More
Struggling with slow EF Core bulk operations? Learn how Entity Framework Extensions cuts insert/update/delete times by up to 95% with BulkInsert, BulkUpdate, and more.
SQL Transactions Explained: Master the ACID Properties
Learn everything about SQL database transactions and ACID properties. A complete beginner's guide to using BEGIN, COMMIT, and ROLLBACK to prevent data corruption.
Why SELECT Is Not Executed First in SQL (Beginner Guide)
Learn the real SQL execution order step by step. Understand why SELECT runs last, avoid common mistakes, and ace SQL interviews with clarity.