| title | Global Secondary Indexes | |||||
|---|---|---|---|---|---|---|
| category | advanced-topics | |||||
| order | 2 | |||||
| keywords |
|
|||||
| related |
|
Documentation > Advanced Topics > Global Secondary Indexes
Previous: Composite Entities | Next: STS Integration
Global Secondary Indexes (GSIs) enable alternative query patterns on your DynamoDB tables. This guide covers GSI configuration, generated code, and best practices for using GSIs with Oproto.FluentDynamoDb.
Define a GSI using the [GlobalSecondaryIndex] attribute:
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("users")]
public partial class User
{
// Primary table keys
[PartitionKey]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
// GSI partition key
[GlobalSecondaryIndex("EmailIndex", IsPartitionKey = true)]
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
}Generated GSI Constants:
public static class UserIndexes
{
public const string EmailIndex = "EmailIndex";
}Add a sort key to your GSI for range queries:
[DynamoDbTable("orders")]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string OrderId { get; set; } = string.Empty;
// GSI partition key
[GlobalSecondaryIndex("StatusIndex", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
// GSI sort key
[GlobalSecondaryIndex("StatusIndex", IsSortKey = true)]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
}Define multiple GSIs on the same entity for different access patterns:
[DynamoDbTable("products")]
public partial class Product
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string ProductId { get; set; } = string.Empty;
// GSI 1: Query by category
[GlobalSecondaryIndex("CategoryIndex", IsPartitionKey = true)]
[DynamoDbAttribute("category")]
public string Category { get; set; } = string.Empty;
[GlobalSecondaryIndex("CategoryIndex", IsSortKey = true)]
[DynamoDbAttribute("price")]
public decimal Price { get; set; }
// GSI 2: Query by vendor
[GlobalSecondaryIndex("VendorIndex", IsPartitionKey = true)]
[DynamoDbAttribute("vendorId")]
public string VendorId { get; set; } = string.Empty;
[GlobalSecondaryIndex("VendorIndex", IsSortKey = true)]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
// GSI 3: Query by status
[GlobalSecondaryIndex("StatusIndex", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = "active";
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
}Generated Code:
public static class ProductIndexes
{
public const string CategoryIndex = "CategoryIndex";
public const string VendorIndex = "VendorIndex";
public const string StatusIndex = "StatusIndex";
}Combine GSIs with computed keys for advanced patterns:
[DynamoDbTable("transactions")]
public partial class Transaction
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string TransactionId { get; set; } = string.Empty;
// Source properties
public string TenantId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
// GSI partition key: "TENANT#tenant123#STATUS#pending"
[GlobalSecondaryIndex("TenantStatusIndex", IsPartitionKey = true)]
[Computed(nameof(TenantId), nameof(Status), Format = "TENANT#{0}#STATUS#{1}")]
[DynamoDbAttribute("gsi1pk")]
public string TenantStatusKey { get; set; } = string.Empty;
// GSI sort key: ISO 8601 timestamp
[GlobalSecondaryIndex("TenantStatusIndex", IsSortKey = true)]
[Computed(nameof(CreatedAt), Format = "{0:o}")]
[DynamoDbAttribute("gsi1sk")]
public string CreatedAtKey { get; set; } = string.Empty;
}Use Case: Query all pending transactions for a tenant, sorted by creation date.
The source generator creates field constants for GSI attributes:
// Generated: OrderFields.g.cs
public static class OrderFields
{
// Main table fields
public const string OrderId = "pk";
public const string Status = "status";
public const string CreatedAt = "createdAt";
public const string CustomerId = "customerId";
public const string Total = "total";
// GSI-specific nested class
public static class StatusIndex
{
public const string Status = "status";
public const string CreatedAt = "createdAt";
}
}Usage:
// Use main table fields
await table.Get
.WithKey(OrderFields.OrderId, "order123")
.ExecuteAsync<Order>();
// Use GSI fields
await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();The source generator creates key builder methods for GSI keys:
// Generated: OrderKeys.g.cs
public static class OrderKeys
{
// Main table keys
public static string Pk(string orderId) => orderId;
// GSI key builders (nested class)
public static class StatusIndex
{
public static string Pk(string status) => status;
public static string Sk(DateTime createdAt) => createdAt.ToString("o");
}
}Usage:
// Build GSI partition key
var statusKey = OrderKeys.StatusIndex.Pk("pending"); // Returns "pending"
// Build GSI sort key
var dateKey = OrderKeys.StatusIndex.Sk(DateTime.UtcNow); // Returns ISO 8601 timestamp
// Use in query
await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}",
OrderKeys.StatusIndex.Pk("pending"))
.ExecuteAsync<Order>();For computed GSI keys, the generator creates appropriate builder methods:
[DynamoDbTable("events")]
public partial class Event
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string EventId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
// Computed GSI key
[GlobalSecondaryIndex("TenantTypeIndex", IsPartitionKey = true)]
[Computed(nameof(TenantId), nameof(EventType), Format = "TENANT#{0}#TYPE#{1}")]
[DynamoDbAttribute("gsi1pk")]
public string TenantTypeKey { get; set; } = string.Empty;
}Generated:
public static class EventKeys
{
public static string Pk(string eventId) => eventId;
public static class TenantTypeIndex
{
public static string Pk(string tenantId, string eventType)
=> $"TENANT#{tenantId}#TYPE#{eventType}";
}
}Usage:
// Build computed GSI key
var gsiKey = EventKeys.TenantTypeIndex.Pk("tenant123", "LOGIN");
// Returns: "TENANT#tenant123#TYPE#LOGIN"
// Use in query
await table.Query
.UsingIndex(EventIndexes.TenantTypeIndex)
.Where($"{EventFields.TenantTypeIndex.TenantTypeKey} = {{0}}",
EventKeys.TenantTypeIndex.Pk("tenant123", "LOGIN"))
.ExecuteAsync<Event>();Query a GSI using expression formatting:
// Query orders by status
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
foreach (var order in response.Items)
{
Console.WriteLine($"Order {order.OrderId}: ${order.Total}");
}Query with sort key conditions:
// Query pending orders created in the last 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}} AND {OrderFields.StatusIndex.CreatedAt} > {{1:o}}",
"pending",
sevenDaysAgo)
.ExecuteAsync<Order>();Add filter expressions for additional filtering:
// Query pending orders over $100
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.WithFilter($"{OrderFields.Total} > {{0}}", 100.00m)
.ExecuteAsync<Order>();Note: Filter expressions are applied after the query, so they don't reduce read capacity consumption.
Paginate through large result sets:
var allOrders = new List<Order>();
string? lastEvaluatedKey = null;
do
{
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.Take(100)
.WithExclusiveStartKey(lastEvaluatedKey)
.ExecuteAsync<Order>();
allOrders.AddRange(response.Items);
lastEvaluatedKey = response.LastEvaluatedKey;
} while (lastEvaluatedKey != null);
Console.WriteLine($"Found {allOrders.Count} pending orders");DynamoDB GSIs support three projection types:
- KEYS_ONLY - Only key attributes
- INCLUDE - Keys plus specified attributes
- ALL - All attributes (default)
Note: Projection type is configured in your DynamoDB table definition, not in the entity class.
When using KEYS_ONLY or INCLUDE projections, only projected attributes are returned:
// GSI configured with KEYS_ONLY projection
// Only returns: pk, status, createdAt
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
// order.OrderId, order.Status, order.CreatedAt are populated
// order.CustomerId, order.Total may be null/defaultTo get full items when using sparse projections:
// Step 1: Query GSI for keys
var gsiResponse = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
// Step 2: Batch get full items
var batchGetBuilder = new BatchGetItemRequestBuilder(client);
foreach (var order in gsiResponse.Items)
{
batchGetBuilder.Get(table, builder => builder
.WithKey(OrderFields.OrderId, order.OrderId));
}
var fullItems = await batchGetBuilder.ExecuteAsync();Trade-off: Two operations vs. larger GSI storage and throughput costs.
✅ Use KEYS_ONLY when:
- You only need to identify items
- You'll fetch full items in a second operation
- Minimizing GSI storage costs is important
✅ Use INCLUDE when:
- You need specific attributes for filtering/display
- You want to avoid a second query
- The included attributes are relatively small
✅ Use ALL when:
- You need all attributes in query results
- Storage cost is not a concern
- You want simplest query logic
// Example: INCLUDE projection with commonly needed fields
// GSI projects: pk, status, createdAt, customerId, total
// Omits: large description field, metadata
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
// All projected fields are available
// No need for second query in most casesQuery items by status with time-based sorting:
[DynamoDbTable("tasks")]
public partial class Task
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string TaskId { get; set; } = string.Empty;
[GlobalSecondaryIndex("StatusIndex", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
[GlobalSecondaryIndex("StatusIndex", IsSortKey = true)]
[DynamoDbAttribute("dueDate")]
public DateTime DueDate { get; set; }
[DynamoDbAttribute("title")]
public string Title { get; set; } = string.Empty;
}Access Pattern: Get all pending tasks due in the next week
var nextWeek = DateTime.UtcNow.AddDays(7);
var response = await table.Query
.UsingIndex(TaskIndexes.StatusIndex)
.Where($"{TaskFields.StatusIndex.Status} = {{0}} AND {TaskFields.StatusIndex.DueDate} < {{1:o}}",
"pending",
nextWeek)
.ExecuteAsync<Task>();Query items for a specific tenant:
[DynamoDbTable("documents")]
public partial class Document
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string DocumentId { get; set; } = string.Empty;
[GlobalSecondaryIndex("TenantIndex", IsPartitionKey = true)]
[Computed(nameof(TenantId), Format = "TENANT#{0}")]
[DynamoDbAttribute("gsi1pk")]
public string TenantId { get; set; } = string.Empty;
[GlobalSecondaryIndex("TenantIndex", IsSortKey = true)]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
[DynamoDbAttribute("title")]
public string Title { get; set; } = string.Empty;
}Access Pattern: Get all documents for a tenant, newest first
var response = await table.Query
.UsingIndex(DocumentIndexes.TenantIndex)
.Where($"{DocumentFields.TenantIndex.TenantId} = {{0}}",
DocumentKeys.TenantIndex.Pk("tenant123"))
.ScanIndexForward(false) // Descending order
.ExecuteAsync<Document>();Create GSIs that only index items with specific attributes:
[DynamoDbTable("users")]
public partial class User
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
// Only users with premium status are indexed
[GlobalSecondaryIndex("PremiumIndex", IsPartitionKey = true)]
[DynamoDbAttribute("premiumStatus")]
public string? PremiumStatus { get; set; } // null for non-premium users
[GlobalSecondaryIndex("PremiumIndex", IsSortKey = true)]
[DynamoDbAttribute("premiumSince")]
public DateTime? PremiumSince { get; set; }
}Access Pattern: Get all premium users
// Only items with premiumStatus != null are in the index
var response = await table.Query
.UsingIndex(UserIndexes.PremiumIndex)
.Where($"{UserFields.PremiumIndex.PremiumStatus} = {{0}}", "active")
.ExecuteAsync<User>();Benefits:
- Reduced GSI storage costs (only premium users indexed)
- Faster queries (smaller index)
- Automatic filtering (non-premium users excluded)
Create an inverted index for reverse lookups:
[DynamoDbTable("relationships")]
public partial class Relationship
{
// Main table: User -> Followers
[PartitionKey]
[Computed(nameof(UserId), Format = "USER#{0}")]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
[SortKey]
[Computed(nameof(FollowerId), Format = "FOLLOWER#{0}")]
[DynamoDbAttribute("sk")]
public string FollowerId { get; set; } = string.Empty;
// GSI: Inverted index for Follower -> Following
[GlobalSecondaryIndex("InvertedIndex", IsPartitionKey = true)]
[Computed(nameof(FollowerId), Format = "USER#{0}")]
[DynamoDbAttribute("gsi1pk")]
public string InvertedPk { get; set; } = string.Empty;
[GlobalSecondaryIndex("InvertedIndex", IsSortKey = true)]
[Computed(nameof(UserId), Format = "FOLLOWING#{0}")]
[DynamoDbAttribute("gsi1sk")]
public string InvertedSk { get; set; } = string.Empty;
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
}Access Patterns:
// Pattern 1: Get all followers of a user (main table)
var followers = await table.Query
.Where($"{RelationshipFields.UserId} = {{0}}",
RelationshipKeys.Pk("user123"))
.ExecuteAsync<Relationship>();
// Pattern 2: Get all users that a user is following (GSI)
var following = await table.Query
.UsingIndex(RelationshipIndexes.InvertedIndex)
.Where($"{RelationshipFields.InvertedIndex.InvertedPk} = {{0}}",
RelationshipKeys.InvertedIndex.Pk("user123"))
.ExecuteAsync<Relationship>();Use composite GSI keys to enable efficient filtering:
[DynamoDbTable("products")]
public partial class Product
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string ProductId { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public decimal Price { get; set; }
// Composite GSI key: "CATEGORY#electronics#STATUS#active"
[GlobalSecondaryIndex("CategoryStatusIndex", IsPartitionKey = true)]
[Computed(nameof(Category), nameof(Status), Format = "CATEGORY#{0}#STATUS#{1}")]
[DynamoDbAttribute("gsi1pk")]
public string CategoryStatusKey { get; set; } = string.Empty;
[GlobalSecondaryIndex("CategoryStatusIndex", IsSortKey = true)]
[DynamoDbAttribute("price")]
public decimal PriceKey { get; set; }
}Access Pattern: Get active products in a category, sorted by price
var response = await table.Query
.UsingIndex(ProductIndexes.CategoryStatusIndex)
.Where($"{ProductFields.CategoryStatusIndex.CategoryStatusKey} = {{0}}",
ProductKeys.CategoryStatusIndex.Pk("electronics", "active"))
.ExecuteAsync<Product>();
// Results are automatically sorted by price (GSI sort key)GSI queries consume read capacity from the GSI, not the main table:
// Consumes RCUs from StatusIndex
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();Capacity Calculation:
- Eventually consistent: 1 RCU per 8KB
- Strongly consistent: Not supported on GSIs
- Query returns 40KB: 5 RCUs (40KB / 8KB, rounded up)
Every write to the main table that affects GSI keys consumes write capacity on both:
// Consumes WCUs on:
// 1. Main table
// 2. StatusIndex (status or createdAt changed)
// 3. VendorIndex (vendorId or createdAt changed)
await table.Put
.WithItem(product)
.ExecuteAsync();Best Practice: Minimize GSI updates by:
- Using sparse indexes (null values not indexed)
- Avoiding frequently updated attributes as GSI keys
- Batching updates when possible
GSIs consume additional storage:
// Main table item: 10KB
// GSI with ALL projection: Additional 10KB
// GSI with KEYS_ONLY: Additional ~1KB
// Total storage: 10KB + 10KB + 1KB = 21KBOptimization:
- Use KEYS_ONLY or INCLUDE projections
- Use sparse indexes to reduce item count
- Remove unnecessary GSIs
✅ Efficient GSI Queries:
// Good: Specific partition key
await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
// Good: Partition key + sort key range
await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}} AND {OrderFields.StatusIndex.CreatedAt} > {{1:o}}",
"pending", sevenDaysAgo)
.ExecuteAsync<Order>();❌ Inefficient GSI Queries:
// Bad: Scan entire GSI (no partition key)
// Note: Requires [Scannable] attribute on table class
var response = await table.Scan()
.UsingIndex(OrderIndexes.StatusIndex)
.WithFilter($"{OrderFields.Total} > {{0}}", 100.00m)
.ExecuteAsync();
// Bad: Filter expression does heavy lifting
await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.WithFilter($"{OrderFields.CustomerId} = {{0}} AND {OrderFields.Total} > {{1}}",
"customer123", 100.00m)
.ExecuteAsync<Order>();
// Better: Create a GSI with CustomerId as partition key// ✅ Good - GSI matches query pattern
// Access pattern: "Get all pending orders for a customer"
[GlobalSecondaryIndex("CustomerStatusIndex", IsPartitionKey = true)]
[Computed(nameof(CustomerId), nameof(Status), Format = "{0}#{1}")]
[DynamoDbAttribute("gsi1pk")]
public string CustomerStatusKey { get; set; } = string.Empty;
// Query efficiently
await table.Query
.UsingIndex(OrderIndexes.CustomerStatusIndex)
.Where($"{OrderFields.CustomerStatusIndex.CustomerStatusKey} = {{0}}",
OrderKeys.CustomerStatusIndex.Pk("customer123", "pending"))
.ExecuteAsync<Order>();// ✅ Good - only index items that need it
[GlobalSecondaryIndex("ErrorIndex", IsPartitionKey = true)]
[DynamoDbAttribute("errorCode")]
public string? ErrorCode { get; set; } // null for successful items
// Only failed items are indexed
// Reduces storage and improves query performance// ✅ Good - KEYS_ONLY for lookup, then batch get
var keys = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ExecuteAsync<Order>();
// Batch get full items
var fullOrders = await BatchGetFullItems(keys.Items.Select(o => o.OrderId));
// ✅ Good - INCLUDE for common fields
// GSI includes: pk, status, createdAt, customerId, total
// Omits: large description, metadata// ❌ Avoid - all items have same GSI partition key
[GlobalSecondaryIndex("TypeIndex", IsPartitionKey = true)]
[DynamoDbAttribute("type")]
public string Type { get; set; } = "ORDER"; // Same for all orders
// ✅ Better - distribute across multiple partitions
[GlobalSecondaryIndex("StatusDateIndex", IsPartitionKey = true)]
[Computed(nameof(Status), nameof(CreatedDate), Format = "{0}#{1:yyyy-MM-dd}")]
[DynamoDbAttribute("gsi1pk")]
public string StatusDateKey { get; set; } = string.Empty;
// Distributes items across dates// Monitor consumed capacity
var response = await table.Query
.UsingIndex(OrderIndexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.WithReturnConsumedCapacity(ReturnConsumedCapacity.TOTAL)
.ExecuteAsync<Order>();
Console.WriteLine($"Consumed capacity: {response.ConsumedCapacity?.CapacityUnits} RCUs");- Composite Entities - Combine GSIs with multi-item entities
- Performance Optimization - Optimize GSI queries
- Querying Data - Advanced query patterns
- Entity Definition - GSI attribute configuration
Previous: Composite Entities | Next: STS Integration
See Also: