Back to Blog

Advanced Database Indexing: PostgreSQL vs SQL Server for .NET Developers

Master clustered indexes, covering indexes, partial indexes, and advanced indexing strategies in PostgreSQL and SQL Server. Learn when to use each index type and how to optimize queries in your .NET applications.

Antonio Supan
Antonio SupanFullstack Engineer

Advanced Database Indexing: PostgreSQL vs SQL Server for .NET Developers

Indexes are the backbone of database performance, yet many .NET developers treat them as an afterthought. Understanding the internals of how indexes work in PostgreSQL and SQL Server will transform how you design schemas and write queries. This article dives deep into clustered indexes, advanced index types, and practical optimization strategies.

Index Fundamentals: B-Tree Architecture

Both PostgreSQL and SQL Server use B-Tree (Balanced Tree) as their default index structure. Understanding this architecture is crucial for making informed indexing decisions.

                    [Root Node]
                   /     |     \
          [Branch]   [Branch]   [Branch]
          /    \      /    \      /    \
      [Leaf] [Leaf] [Leaf] [Leaf] [Leaf] [Leaf]

Key characteristics:

  • Logarithmic search complexity: O(log n)
  • Leaf nodes contain actual data pointers (or data itself in clustered indexes)
  • Self-balancing during inserts and deletes
  • Optimal for range queries and equality searches

Clustered Indexes: The Fundamental Difference

The most significant difference between PostgreSQL and SQL Server lies in how they handle clustered indexes.

SQL Server: Clustered Index as Table Structure

In SQL Server, a clustered index is the table. The leaf level of a clustered index contains the actual data rows.

sql
-- SQL Server: Creating a clustered index
CREATE CLUSTERED INDEX IX_Orders_OrderDate
ON Orders(OrderDate);

-- Or through primary key (default behavior)
ALTER TABLE Orders
ADD CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId);

Critical implications:

  1. Only one clustered index per table - The data can only be physically sorted one way
  2. Non-clustered indexes store the clustering key - Not row pointers
  3. Key width matters enormously - Wide clustering keys bloat all non-clustered indexes
csharp
// Entity Framework: Specifying clustered index in SQL Server
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        // This creates a clustered index by default
        entity.HasKey(e => e.OrderId);

        // Or explicitly configure clustering
        entity.HasIndex(e => e.OrderDate)
            .IsClustered();
    });
}

PostgreSQL: Heap Tables with Optional Clustering

PostgreSQL uses heap tables by default. Tables are unordered, and all indexes (including primary keys) are non-clustered.

sql
-- PostgreSQL: CLUSTER command reorganizes table data
CLUSTER orders USING ix_orders_order_date;

-- This is a ONE-TIME operation, not maintained automatically!
-- New rows go to the heap in arrival order

PostgreSQL's approach:

  1. All indexes are secondary - They point to tuple identifiers (ctid)
  2. HOT updates - Heap-Only Tuples allow updates without index maintenance
  3. CLUSTER is not automatic - Must be rerun periodically if needed
csharp
// Npgsql with EF Core: Index configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        entity.HasIndex(e => e.OrderDate)
            .HasMethod("btree"); // Default, but explicit
    });
}

Covering Indexes: Eliminating Key Lookups

A covering index contains all columns needed to satisfy a query, eliminating the need to access the base table.

SQL Server: INCLUDE Clause

sql
-- SQL Server: Covering index with INCLUDE
CREATE NONCLUSTERED INDEX IX_Orders_CustomerId_Covering
ON Orders(CustomerId)
INCLUDE (OrderDate, TotalAmount, Status);

The INCLUDE clause adds columns to the leaf level without affecting the index key order.

sql
-- This query is now fully covered
SELECT OrderDate, TotalAmount, Status
FROM Orders
WHERE CustomerId = @CustomerId;

PostgreSQL: Similar INCLUDE Support (v11+)

sql
-- PostgreSQL 11+: INCLUDE syntax
CREATE INDEX ix_orders_customer_covering
ON orders(customer_id)
INCLUDE (order_date, total_amount, status);

When to Use Covering Indexes

csharp
// Common EF Core query pattern that benefits from covering index
var customerOrders = await context.Orders
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderSummaryDto
    {
        OrderDate = o.OrderDate,
        TotalAmount = o.TotalAmount,
        Status = o.Status
    })
    .ToListAsync();

Create an index that covers exactly this query:

csharp
// EF Core 7+ index configuration
modelBuilder.Entity<Order>()
    .HasIndex(e => e.CustomerId)
    .IncludeProperties(e => new { e.OrderDate, e.TotalAmount, e.Status });

Partial (Filtered) Indexes: Index Only What Matters

Partial indexes index a subset of rows, reducing storage and maintenance overhead.

PostgreSQL: WHERE Clause in Index

sql
-- PostgreSQL: Partial index for active orders
CREATE INDEX ix_orders_active
ON orders(customer_id, order_date)
WHERE status = 'Active';

-- Only indexes rows where status = 'Active'
-- Dramatically smaller than a full index

SQL Server: Filtered Indexes

sql
-- SQL Server: Filtered index
CREATE NONCLUSTERED INDEX IX_Orders_Active
ON Orders(CustomerId, OrderDate)
WHERE Status = 'Active';

Practical Use Cases

csharp
// Query that benefits from partial index
var activeOrders = await context.Orders
    .Where(o => o.Status == OrderStatus.Active)
    .Where(o => o.CustomerId == customerId)
    .OrderByDescending(o => o.OrderDate)
    .ToListAsync();

Common patterns for partial indexes:

  1. Soft deletes: Index only non-deleted rows
  2. Status flags: Index only active/pending records
  3. Null exclusion: Index only rows with non-null values
  4. Recent data: Index only data from last N months
sql
-- PostgreSQL: Index only non-deleted records
CREATE INDEX ix_users_active_email
ON users(email)
WHERE deleted_at IS NULL;

-- SQL Server equivalent
CREATE NONCLUSTERED INDEX IX_Users_Active_Email
ON Users(Email)
WHERE DeletedAt IS NULL;

Expression Indexes: Indexing Computed Values

PostgreSQL: Full Expression Support

PostgreSQL excels at expression indexes, allowing you to index computed values.

sql
-- Index on lowercase email for case-insensitive searches
CREATE INDEX ix_users_email_lower ON users(LOWER(email));

-- Index on extracted year
CREATE INDEX ix_orders_year ON orders(EXTRACT(YEAR FROM order_date));

-- Index on JSONB path
CREATE INDEX ix_products_category
ON products((metadata->>'category'));

Query usage:

csharp
// EF Core query that uses the expression index
var users = await context.Users
    .Where(u => EF.Functions.Lower(u.Email) == email.ToLower())
    .ToListAsync();

SQL Server: Computed Columns + Index

SQL Server requires a computed column first:

sql
-- SQL Server: Add computed column, then index it
ALTER TABLE Users
ADD EmailLower AS LOWER(Email) PERSISTED;

CREATE NONCLUSTERED INDEX IX_Users_EmailLower
ON Users(EmailLower);

Or use indexed views for more complex expressions.

Multi-Column Index Strategy: Column Order Matters

The order of columns in a composite index is critical for query optimization.

The Leftmost Prefix Rule

An index on (A, B, C) can efficiently support queries filtering on:

  • A
  • A, B
  • A, B, C

But NOT efficiently:

  • B alone
  • C alone
  • B, C
sql
-- This index
CREATE INDEX ix_orders_composite
ON orders(customer_id, status, order_date);

-- Efficiently supports
SELECT * FROM orders WHERE customer_id = 1;
SELECT * FROM orders WHERE customer_id = 1 AND status = 'Active';
SELECT * FROM orders WHERE customer_id = 1 AND status = 'Active' AND order_date > '2024-01-01';

-- Does NOT efficiently support
SELECT * FROM orders WHERE status = 'Active'; -- Full index scan
SELECT * FROM orders WHERE order_date > '2024-01-01'; -- Full index scan

Column Order Guidelines

  1. Equality columns first: Columns used with = before range columns
  2. High selectivity first: Columns that filter more rows
  3. Consider query patterns: Match your most common queries
sql
-- Given these queries:
-- 1. WHERE customer_id = ? AND status = ?
-- 2. WHERE customer_id = ? AND order_date BETWEEN ? AND ?
-- 3. WHERE customer_id = ?

-- Optimal index:
CREATE INDEX ix_orders_optimal
ON orders(customer_id, status, order_date);

Index Types Beyond B-Tree

PostgreSQL: Specialized Index Types

sql
-- GiST: Geometric and full-text search
CREATE INDEX ix_locations_geom ON locations USING gist(coordinates);

-- GIN: Arrays, JSONB, full-text
CREATE INDEX ix_products_tags ON products USING gin(tags);
CREATE INDEX ix_documents_search ON documents USING gin(to_tsvector('english', content));

-- BRIN: Block Range Index for large sequential data
CREATE INDEX ix_events_time ON events USING brin(created_at);

-- Hash: Equality-only, memory-efficient
CREATE INDEX ix_sessions_token ON sessions USING hash(token);

SQL Server: Specialized Index Types

sql
-- Columnstore: Analytics workloads
CREATE CLUSTERED COLUMNSTORE INDEX CCI_Sales ON Sales;

-- Spatial: Geographic data
CREATE SPATIAL INDEX IX_Locations_Geo ON Locations(GeoPoint);

-- Full-text: Text search
CREATE FULLTEXT INDEX ON Documents(Content) KEY INDEX PK_Documents;

-- XML: XML data
CREATE PRIMARY XML INDEX IX_Config_Xml ON Config(XmlData);

Index Maintenance and Monitoring

SQL Server: Fragmentation Analysis

sql
-- Check index fragmentation
SELECT
    OBJECT_NAME(ips.object_id) AS TableName,
    i.name AS IndexName,
    ips.avg_fragmentation_in_percent,
    ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 10
ORDER BY ips.avg_fragmentation_in_percent DESC;

-- Rebuild or reorganize
ALTER INDEX IX_Orders_CustomerId ON Orders REBUILD;
ALTER INDEX IX_Orders_CustomerId ON Orders REORGANIZE;

PostgreSQL: Bloat and Statistics

sql
-- Check table and index bloat
SELECT
    schemaname, tablename,
    pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as total_size,
    pg_size_pretty(pg_indexes_size(schemaname || '.' || tablename)) as index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;

-- Reindex
REINDEX INDEX CONCURRENTLY ix_orders_customer;

-- Update statistics
ANALYZE orders;

Monitoring from .NET

csharp
public class IndexHealthService
{
    private readonly IDbConnection _connection;

    public async Task<IEnumerable<IndexStats>> GetFragmentedIndexesAsync()
    {
        // SQL Server
        const string sql = @"
            SELECT
                OBJECT_NAME(ips.object_id) AS TableName,
                i.name AS IndexName,
                ips.avg_fragmentation_in_percent AS FragmentationPercent
            FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
            JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
            WHERE ips.avg_fragmentation_in_percent > 30
            AND i.name IS NOT NULL";

        return await _connection.QueryAsync<IndexStats>(sql);
    }
}

Entity Framework Core: Index Best Practices

Fluent API Configuration

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        // Composite index with specific order
        entity.HasIndex(e => new { e.CustomerId, e.Status, e.OrderDate })
            .HasDatabaseName("IX_Orders_Customer_Status_Date");

        // Covering index (EF Core 7+)
        entity.HasIndex(e => e.CustomerId)
            .IncludeProperties(e => new { e.OrderDate, e.TotalAmount })
            .HasDatabaseName("IX_Orders_Customer_Covering");

        // Filtered index
        entity.HasIndex(e => e.CustomerId)
            .HasFilter("[Status] = 'Active'")
            .HasDatabaseName("IX_Orders_Active");

        // Unique index
        entity.HasIndex(e => e.OrderNumber)
            .IsUnique()
            .HasDatabaseName("UX_Orders_OrderNumber");
    });
}

Migration with Raw SQL for Advanced Indexes

csharp
public partial class AddAdvancedIndexes : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // PostgreSQL: Expression index
        migrationBuilder.Sql(@"
            CREATE INDEX ix_users_email_lower
            ON users(LOWER(email))");

        // PostgreSQL: Partial index with covering
        migrationBuilder.Sql(@"
            CREATE INDEX ix_orders_active_covering
            ON orders(customer_id)
            INCLUDE (order_date, total_amount)
            WHERE status = 'Active'");

        // SQL Server: Filtered covering index
        migrationBuilder.Sql(@"
            CREATE NONCLUSTERED INDEX IX_Orders_Active_Covering
            ON Orders(CustomerId)
            INCLUDE (OrderDate, TotalAmount)
            WHERE Status = 'Active'");
    }
}

Query Plan Analysis

Reading Execution Plans

csharp
// SQL Server: Enable actual execution plan in EF Core
services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging();
});

// Analyze specific query
var query = context.Orders
    .Where(o => o.CustomerId == customerId)
    .TagWith("Analyzing index usage");

var sql = query.ToQueryString();

Common Plan Operations to Watch

OperationSQL ServerPostgreSQLMeaning
Index SeekIndex SeekIndex ScanGood: Using index efficiently
Index ScanIndex ScanSeq ScanWarning: Full index/table scan
Key LookupKey LookupHeap FetchWarning: Consider covering index
SortSortSortWarning: Consider index for ORDER BY

Decision Matrix: Choosing the Right Index

ScenarioRecommended Index Type
Primary key lookupClustered (SQL Server) / B-Tree (PostgreSQL)
Range queries on datesB-Tree on date column
Full-text searchFull-Text Index / GIN
JSON document queriesGIN (PostgreSQL) / Computed column + B-Tree (SQL Server)
Geospatial queriesSpatial / GiST
Analytics on large tablesColumnstore (SQL Server) / BRIN (PostgreSQL)
Case-insensitive searchExpression index (PostgreSQL) / Computed column (SQL Server)
Frequently filtered subsetPartial/Filtered index

Performance Testing Index Impact

csharp
[MemoryDiagnoser]
public class IndexBenchmarks
{
    private AppDbContext _context;

    [GlobalSetup]
    public void Setup()
    {
        _context = new AppDbContext(/* connection */);
    }

    [Benchmark(Baseline = true)]
    public async Task<List<Order>> WithoutCoveringIndex()
    {
        return await _context.Orders
            .Where(o => o.CustomerId == 1)
            .Select(o => new Order { OrderDate = o.OrderDate, TotalAmount = o.TotalAmount })
            .ToListAsync();
    }

    [Benchmark]
    public async Task<List<Order>> WithCoveringIndex()
    {
        // Same query, but after adding covering index
        return await _context.Orders
            .Where(o => o.CustomerId == 1)
            .Select(o => new Order { OrderDate = o.OrderDate, TotalAmount = o.TotalAmount })
            .ToListAsync();
    }
}

Conclusion

Effective indexing is both an art and a science. The key takeaways:

  1. Understand the fundamentals: B-Tree structure, clustered vs. non-clustered differences
  2. Use covering indexes to eliminate key lookups for your critical queries
  3. Leverage partial indexes to reduce index size and maintenance overhead
  4. Consider column order carefully in composite indexes
  5. Monitor and maintain indexes regularly
  6. Test with realistic data - index behavior changes significantly with data volume

Remember that indexes are not free. Each index adds write overhead and storage cost. The goal is to find the optimal balance for your specific workload patterns.

Start by analyzing your slowest queries, check their execution plans, and add targeted indexes. Measure the impact before and after. Repeat.