DynamoDB Data Modeling: Access-Pattern-First Design
Engineers who model DynamoDB entity-first always retrofit key schemas and pay the migration tax — a full table scan and rewrite to change key design after data exists in production. This article covers access-pattern enumeration methodology, generic key overloading, composite sort keys, Adjacency List for many-to-many relationships, overloaded GSIs, sparse indexes, GSI vs. LSI tradeoffs (and the 10 GB item collection trap), and DynamoDB's RCU/WCU cost taxonomy.
Every AWS primitive is a tradeoff surface, not a feature toggle. DynamoDB's partition key is not a primary key in the relational sense — it is a routing decision that determines which storage node handles your request, and changing it after data exists means rewriting every item in the table. Engineers who model entities first and derive access patterns second always discover a query they forgot three months into production — and pay the migration tax: a full table scan, a rewrite script, and a production freeze window to swap key schemas. This article covers the discipline that eliminates that tax: enumerate every query before writing any key schema.
Prerequisite: This article builds on Document & NoSQL Databases: MongoDB & DynamoDB in Production, which covers the embed vs reference decision, basic single-table concepts, and DynamoDB Streams introduction. Those foundations are not repeated here.
The correct entry point for DynamoDB schema design is the API contract, not the data model. Before writing a single attribute name, you must enumerate every read operation the system will perform — including the ones that come from reporting, audit, admin tooling, and mobile clients with different query needs.
Step 1: List every entity in the system
- User, Order, OrderItem, Product, Category, Review, Shipment
Step 2: For each entity, write every query in plain English
- Get user by userId
- Get all orders for a userId
- Get order detail by orderId
- Get all items in an order
- Get all orders in PENDING status (for the fulfillment queue)
- Get all orders placed in the last 24 hours (for the dashboard)
- Get all reviews for a productId
- Get the 10 most recent reviews for a productId
Step 3: Assign key slot to each query
- "Get user by userId" → PK: USER#userId, SK: PROFILE
- "Get all orders for a userId" → PK: USER#userId, SK: begins_with ORDER#
- "Get order detail by orderId" → PK: ORDER#orderId, SK: METADATA
- "Get all items in an order" → PK: ORDER#orderId, SK: begins_with ITEM#
- "Get all orders in PENDING status" → GSI1: PK: STATUS#pending, SK: orderId
- "Get all reviews for a productId" → PK: PRODUCT#productId, SK: begins_with REVIEW#
Step 4: Add entities to the key assignments
- New query cannot be served by existing key slots → revise the schema
- New query can be served by adding a sparse GSI → add carefully (write amplification cost)
- New query requires a full table scan → acceptable only for infrequent admin/batch use
// ❌ Entity-first modeling — three months of naive GSI accumulation// Table design after modeling entities independently:// users: PK = USER#userId, SK = USER#userId// orders: PK = ORDER#ordId, SK = ORDER#ordId// "Need orders by userId" → add GSI1: userId-index// "Need orders by status" → add GSI2: status-index// "Need open orders sorted by created date" → add GSI3: status-date-index// "Need orders by region" → add GSI4: region-index// Six months: 5 GSIs// Write amplification: 1 base table + 5 GSIs = 6× WCU per order write// Bill at 10K orders/day: 60K WCU-writes/day just for write amplification// Redesign migration: 4M existing records × full-table-scan + rewrite// ✅ Access-pattern-first — 1 table, 1 GSI, all queries served
Architectural Note
Cost Nuance Hint: Why does 1 order write suddenly consume 6× write capacity? In DynamoDB, each secondary index operates as an independent physical storage partition. Every write to the base table replicates asynchronously to every GSI projecting that item, billing separate WCUs per 1 KB block per index. If the fundamental unit mechanics (RCU/WCU thresholds, 4 KB block rounding, or On-Demand vs. Provisioned pricing) are unfamiliar, reference Section 8: DynamoDB Cost Taxonomy at the end of this guide.
DynamoDB's single-table design stores multiple entity types in one table by using generic key attribute names (PK, SK) that hold different semantic values for different entity types.
// All entity types share the same PK/SK attribute names// The value prefix encodes the entity type and enables sort key range queries// Key schema (TypeScript DynamoDB Document Client types):interfaceBaseItem {
PK: string// Partition key — entity type + identifier (e.g., "USER#u42")SK: string// Sort key — entity type or relationship (e.g., "PROFILE" or "ORDER#2026-01-15#o99")// Optional GSI keys (set only on items that should appear in that GSI)GSI1PK?: stringGSI1SK?: stringentityType: string// Always set — enables filtering by entity type within a partition
}
// User profile item// Query: "Get user by userId" → GetItem(PK=USER#u42, SK=PROFILE)const userItem = {
PK: 'USER#u42',
SK: 'PROFILE',
entityType: 'USER',
email: 'alice@example.com',
name: 'Alice',
tier: 'PRO',
createdAt: '2026-01-10T12:00:00Z',
}
// Order item — stored under user's partition// Query: "Get all orders for userId" → Query(PK=USER#u42, SK begins_with ORDER#)const orderUnderUser = {
PK: 'USER#u42',
SK: 'ORDER#2026-01-15#o99', // ISO date prefix enables chronological sortentityType: 'ORDER',
orderId: 'o99',
status: 'PENDING',
total: 149.99,
}
// Order item — also stored under its own partition for order-direct access// Query: "Get order detail by orderId" → GetItem(PK=ORDER#o99, SK=METADATA)const orderDirect = {
PK: 'ORDER#o99',
SK: 'METADATA',
entityType: 'ORDER',
userId: 'u42',
status: 'PENDING',
total: 149.99,
// GSI key: enables "Get all orders in PENDING status" queryGSI1PK: 'STATUS#PENDING',
GSI1SK: '2026-01-15T12:00:00Z#o99', // ISO timestamp + orderId for chronological sort
}
// Sort key design with date prefix enables time-range queries without a GSI// Query: "Get orders for user in January 2026"// → Query(PK=USER#u42, SK between ORDER#2026-01 and ORDER#2026-02)const jan15Order = {
PK: 'USER#u42',
SK: 'ORDER#2026-01-15#o99', // ISO date → lexicographic sort = chronological sort
}
const jan22Order = {
PK: 'USER#u42',
SK: 'ORDER#2026-01-22#o103',
}
const feb01Order = {
PK: 'USER#u42',
SK: 'ORDER#2026-02-01#o108',
}
// KeyConditionExpression: SK between 'ORDER#2026-01' and 'ORDER#2026-02'// Returns jan15Order and jan22Order — feb01Order excluded without a filter expression
// One-to-one: same PK, different SK discriminator// User profile and user settings live in the same partition → single-round-trip fetch with BatchGetconst profile = { PK: 'USER#u42', SK: 'PROFILE', entityType: 'USER', name: 'Alice' }
const settings = { PK: 'USER#u42', SK: 'SETTINGS', entityType: 'USER_SETTINGS', theme: 'dark', locale: 'en-US' }
const billingInfo = { PK: 'USER#u42', SK: 'BILLING', entityType: 'USER_BILLING', stripeId: 'cus_abc' }
// Fetch all three with one TransactGetItems call:// [GetItem(USER#u42/PROFILE), GetItem(USER#u42/SETTINGS), GetItem(USER#u42/BILLING)]// One-to-many: parent PK, children prefixed under same partition// Query: "Get all items in order o99" → Query(PK=ORDER#o99, SK begins_with ITEM#)const orderMeta = { PK: 'ORDER#o99', SK: 'METADATA', entityType: 'ORDER', total: 149.99 }
const orderItem1 = { PK: 'ORDER#o99', SK: 'ITEM#prod-1', entityType: 'ORDER_ITEM', quantity: 2, unitPrice: 49.99 }
const orderItem2 = { PK: 'ORDER#o99', SK: 'ITEM#prod-2', entityType: 'ORDER_ITEM', quantity: 1, unitPrice: 49.99 }
// One Query call returns metadata + all line items — no joins, no second round trip
Pro Tip & Optimization
Cost Nuance Hint (RCU Aggregation): Co-locating parent and children under the same partition key delivers a massive economic dividend. A single Query sums the sizes of all matched items in the partition before rounding up to the nearest 4 KB boundary (e.g., ten 300-byte items = 3 KB total = 1 RCU). Fetching those same 10 items via separate GetItem or BatchGetItem calls rounds each individual item to 4 KB (costing 10 RCUs). Co-location yields a 90% read discount! See Section 8.1 for the exact boundary math.
The Adjacency List pattern represents many-to-many relationships by storing each relationship as two items — one in each direction.
TYPESCRIPT
// Use case: User ↔ Group membership (many-to-many)// Query 1: "Get all groups that user u42 belongs to"// → Query(PK=USER#u42, SK begins_with GROUP#)// Query 2: "Get all members of group g7"// → Query(GSI1, GSI1PK=GROUP#g7, GSI1SK begins_with USER#)// Item direction A: user → group (in user's partition)const membershipA = {
PK: 'USER#u42', // User's partitionSK: 'GROUP#g7', // Sort key encodes the groupentityType: 'MEMBERSHIP',
joinedAt: '2026-01-10T12:00:00Z',
role: 'member',
}
// Item direction B: group → user (in group's "partition" via GSI)const membershipB = {
PK: 'GROUP#g7', // ← Different from item A: group's partitionSK: 'USER#u42', // ← Inverted: user is now the sort keyentityType: 'MEMBERSHIP',
joinedAt: '2026-01-10T12:00:00Z',
role: 'member',
// GSI key — enables query by groupGSI1PK: 'GROUP#g7',
GSI1SK: 'USER#u42',
}
// Write: both items written atomically with TransactWriteItems// Note: TransactWriteItems incurs a 2× WCU multiplier (two-phase commit) — writing both 1 KB items consumes 4 WCU minimum (see Section 8.2)await docClient.send(newTransactWriteCommand({
TransactItems: [
{ Put: { TableName: 'AppTable', Item: membershipA } },
{ Put: { TableName: 'AppTable', Item: membershipB } },
]
}))
// Query 1: all groups for user u42const userGroups = await docClient.send(newQueryCommand({
TableName: 'AppTable',
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
ExpressionAttributeValues: { ':pk': 'USER#u42', ':prefix': 'GROUP#' },
}))
// Query 2: all members of group g7 (via GSI)const groupMembers = await docClient.send(newQueryCommand({
TableName: 'AppTable',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk AND begins_with(GSI1SK, :prefix)',
ExpressionAttributeValues: { ':pk': 'GROUP#g7', ':prefix': 'USER#' },
}))
Expand
Adjacency List many-to-many (User ↔ Group): base table items with PK/SK values labeled per direction + GSI1 inverted items enabling the Group→Members query;…
A sparse index only indexes items that carry the GSI key attributes. Items without the GSI key attributes are not indexed — they are completely invisible to the GSI and pay zero write amplification.
TYPESCRIPT
// Use case: "Get all orders in PENDING status"// Only PENDING orders carry GSI1PK — other statuses do not → sparse index on status// PENDING order → GSI1PK set → appears in GSI → findable via "pending orders" queryconst pendingOrder = {
PK: 'ORDER#o99',
SK: 'METADATA',
status: 'PENDING',
GSI1PK: 'STATUS#PENDING', // ← set for PENDING orders onlyGSI1SK: '2026-01-15T12:00:00Z#o99',
}
// DELIVERED order → GSI1PK NOT set → does NOT appear in GSI → no write amplificationconst deliveredOrder = {
PK: 'ORDER#o88',
SK: 'METADATA',
status: 'DELIVERED',
// GSI1PK and GSI1SK intentionally absent// This item is invisible to GSI1 and costs 0 additional WCU for GSI writes
}
// Query: "Get all pending orders, sorted by date"const pendingOrders = await docClient.send(newQueryCommand({
TableName: 'AppTable',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :status',
ExpressionAttributeValues: { ':status': 'STATUS#PENDING' },
ScanIndexForward: true, // chronological order via GSI1SK
}))
Pro Tip & Optimization
Sparse indexes are ideal for "find all items of type X that are in state Y" queries — where only a small fraction of items match state Y at any given time. Fulfilled orders, resolved support tickets, and expired sessions are all poor sparse index candidates (too many items qualify). Active/pending/open state items are perfect candidates — low cardinality, high query frequency, low write amplification.
Expand
DynamoDB design process flow: Enumerate queries (correct entry point, cyan) → Assign entities to key slots → Identify GSI access patterns → Assign GSI keys;…
An overloaded GSI uses polymorphic GSI1PK/GSI1SK values to serve multiple entity types with different query patterns — reducing the total GSI count and its associated write amplification.
TYPESCRIPT
// GSI1 serves TWO completely different queries:// Query A: "Get all reviews for a productId"// Query B: "Get all orders in PENDING status"// Review item — uses GSI1 for product review listingconst review = {
PK: 'USER#u42',
SK: 'REVIEW#prod-1#2026-01-15',
entityType: 'REVIEW',
rating: 5,
text: 'Excellent product',
GSI1PK: 'PRODUCT#prod-1', // ← Route this entity type through GSI1GSI1SK: 'REVIEW#2026-01-15T12:00:00Z',
}
// Order item — also uses GSI1 but with different key semanticsconst order = {
PK: 'ORDER#o99',
SK: 'METADATA',
entityType: 'ORDER',
status: 'PENDING',
GSI1PK: 'STATUS#PENDING', // ← Same GSI1, different prefixGSI1SK: '2026-01-15T12:00:00Z#o99',
}
// Both queries served by one GSI — not two:// Reviews: Query(GSI1, GSI1PK=PRODUCT#prod-1)// Orders: Query(GSI1, GSI1PK=STATUS#PENDING)// FilterExpression: entityType = 'REVIEW' or 'ORDER' if mixed results are a concern
Crucial Requirement
Write amplification scales with GSI count: every item write to the base table triggers one additional write per GSI that indexes that item. At 5 GSIs, an item that appears in all 5 costs 6× WCU per write (1 base + 5 GSI). Model the WCU impact before adding each GSI: WCU_per_write × write_rate × (1 + GSI_count) × item_size_KB.
Example Calculation:
Writing 2 KB items at 250 writes/sec to a table with 4 GSIs indexing the items:
In previous sections, we leveraged Global Secondary Indexes (GSIs) to invert relationship keys (Section 4), filter sparse entities (Section 5), and overload generic attributes for diverse queries (Section 6). However, DynamoDB provides two distinct secondary indexing primitives: Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI).
Choosing between them is not simply a question of query syntax—it is an irreversible architectural decision with critical ramifications for partition scalability, consistency, and table lifecycle.
The defining architectural hazard of an LSI is the Item Collection Size Limit.
When an LSI is defined on a table, DynamoDB groups all items that share the same partition key value into an Item Collection. This collection includes the base table items plus the projected index items across all LSIs defined on that table.
Partition Splits Are Prohibited: In a standard DynamoDB table (without LSIs), when a partition key's items exceed 10 GB or traffic exceeds partition throughput thresholds, DynamoDB transparently splits the partition across multiple storage nodes. When an LSI is present, DynamoDB cannot split an item collection across nodes because LSI data must remain strictly co-located on the same node to support strong consistency.
Permanent Write Failure: Once the cumulative size of items under a single PK exceeds 10 GB, DynamoDB immediately fails all subsequent PutItem, UpdateItem, and BatchWriteItem requests for that partition key with:
No In-Place Recovery: You cannot drop the LSI to relieve the table. Because LSIs are immutable, the only recovery path is provisioning a brand-new table without the LSI, migrating the entire dataset via AWS DMS, S3 export/import, or custom pipelines, and cutting over DNS/application configuration.
[!CAUTION]
If your application design stores append-only logs, customer order histories, IoT telemetry, or high-volume children under a single entity PK (e.g., PK: TENANT#t1 or PK: ACCOUNT#a12), an LSI is an existential architectural time bomb. The table will function flawlessly in development and staging, only to permanently reject writes for your largest, most valuable enterprise tenants once their historical data accumulates to 10 GB.
Because of the 10 GB collection limit and the inability to add or remove indexes post-creation, Global Secondary Indexes are the default recommendation for 99% of modern DynamoDB designs.
An LSI should ONLY be chosen if all three of the following conditions are simultaneously true:
Strongly Consistent Reads on an Alternate Sort Key Are Mandatory: Your access pattern cannot tolerate the 10–100ms asynchronous replication lag of a GSI (e.g., real-time inventory ledger reservation where reading stale state causes immediate financial double-allocation).
The Partition Key Has a Mathematically Proven Size Bound: The domain model strictly prevents the item collection from approaching 10 GB. For example, a user profile partition that only ever stores at most 5 setting records totaling under 50 KB throughout the account lifecycle.
The Table Key Schema Will Never Need Index Evolution: You can guarantee that the access pattern will never be retired, renamed, or restructured during the lifespan of the service.
> [!TIP]
> If you do operate an LSI in production, monitor collection growth proactively by including `ReturnItemCollectionMetrics: 'SIZE_ESTIMATE'` on all write commands. Configure CloudWatch Alarms on item collection size estimates to detect partitions approaching limits before `ItemCollectionSizeLimitExceededException` crashes your ingestion pipeline.
---
## 8. DynamoDB Cost Taxonomy
Every key modeling choice in single-table design directly maps to DynamoDB's billing dimensions. Understanding the exact arithmetic behind capacity units prevents architectural decisions that look clean on paper but cause explosive cloud bills in production.
> [!NOTE]
> **Pricing Baseline & Regional Variance:** Cost numbers quoted below (e.g., $1.25/M WRU, $0.25/M RRU, $0.00065/WCU-hour) reflect AWS US-East (N. Virginia) standard rates at the time of writing. AWS rates vary by region (typically 20%–30% higher in regions like `ap-south-1` or `eu-central-1`) and are subject to change by AWS. Use these figures as structural architectural equations rather than fixed price quotes, and verify live rates on the official [AWS DynamoDB Pricing](https://aws.amazon.com/dynamodb/pricing/) page when budgeting production systems.
### 8.1 Read Capacity Units (RCU) and Aggregation Rules
DynamoDB measures read throughput in **Read Capacity Units (RCU)** (or Read Request Units in On-Demand mode):
- **1 RCU** = 1 strongly consistent read per second for an item up to **4 KB**.
- **0.5 RCU** = 1 eventually consistent read per second for an item up to **4 KB** (the default for `GetItem`, `Query`, and `Scan`).
- **2 RCU** = 1 transactional read (`TransactGetItems`) per second for an item up to **4 KB**.
| Item Size | Eventual Read (Default) | Strongly Consistent Read | Transactional Read (`TransactGet`) |
| :--- | :--- | :--- | :--- |
| **0.1 KB – 4.0 KB** | 0.5 RCU | 1.0 RCU | 2.0 RCU |
| **4.1 KB – 8.0 KB** | 1.0 RCU | 2.0 RCU | 4.0 RCU |
| **8.1 KB – 12.0 KB** | 1.5 RCU | 3.0 RCU | 6.0 RCU |
| **12.1 KB – 16.0 KB** | 2.0 RCU | 4.0 RCU | 8.0 RCU |
> [!IMPORTANT]
> **The `Query` vs. `GetItem` Aggregation Rule:**
> When issuing `GetItem` or `BatchGetItem`, DynamoDB rounds each item's size up to the next 4 KB boundary individually. When issuing a `Query`, DynamoDB **aggregates the size of all items returned in the result set first**, and then rounds the combined total to the next 4 KB boundary.
>
> *Example:* Fetching 8 order-item rows (500 bytes each):
> - **Via 8 separate `GetItem` calls:** `8 × ceil(0.5 KB / 4 KB) = 8 × 1 = 8 RCU` (eventual: 4 RCU).
> - **Via 1 single-partition `Query`:** `ceil((8 × 0.5 KB) / 4 KB) = ceil(4.0 KB / 4 KB) = 1 RCU` (eventual: 0.5 RCU).
> - **Result:** Single-table partition co-location reduces read capacity consumption by **87.5%**.
### 8.2 Write Capacity Units (WCU) and Transaction Multipliers
DynamoDB measures write throughput in **Write Capacity Units (WCU)** (or Write Request Units in On-Demand mode):
- **1 WCU** = 1 standard write per second (`PutItem`, `UpdateItem`, `DeleteItem`) for an item up to **1 KB**.
- **2 WCU** = 1 transactional write (`TransactWriteItems`) per second for an item up to **1 KB**.
| Item Size | Standard Write (`Put`/`Update`/`Delete`) | Transactional Write (`TransactWrite`) |
| :--- | :--- | :--- |
| **0.1 KB – 1.0 KB** | 1 WCU | 2 WCU |
| **1.1 KB – 2.0 KB** | 2 WCU | 4 WCU |
| **2.1 KB – 3.0 KB** | 3 WCU | 6 WCU |
| **3.1 KB – 4.0 KB** | 4 WCU | 8 WCU |
- **Two-Phase Commit Tax:** `TransactWriteItems` performs two internal operations per item: a prepare/lock step and a commit step. This is why atomic multi-item writes (such as writing both sides of an Adjacency List relationship) inherently cost `2 × items × ceil(size_KB)` in WCUs.
- **Failed Condition Expression Cost:** If a write includes a `ConditionExpression` (e.g., `attribute_not_exists(PK)`) and the condition evaluates to false, DynamoDB **still consumes the full write capacity units** of the attempted write.
### 8.3 Secondary Index Multipliers and Projection Tax
Every Global Secondary Index (GSI) is a physically separate storage table managed by storage nodes downstream from the base table:
Total Consumed WCU = Base Table WCU + Σ (GSI_i WCU for all matching indexes)
1. **Write Replication Multiplier:** When an item is written or updated on the base table, DynamoDB checks every GSI. If the item contains the GSI partition key, DynamoDB replicates the mutation to that GSI asynchronously, charging the corresponding WCU against the GSI's capacity.
2. **The Projection Tax (`ProjectionType`):**
- `KEYS_ONLY`: Only indexes PK and SK. The projected item is tiny (~100–200 bytes), virtually always consuming only 1 WCU.
- `INCLUDE`: Indexes PK, SK, and specific named attributes. Consumes WCUs based on the combined size of the projected attributes.
- `ALL`: Replicates the **entire base item payload** into the GSI. If your base table item is 3.2 KB, writing it consumes 4 WCU on the base table **and 4 WCU on every GSI projecting `ALL`**!
3. **The Sparse Index Dividend:** If an item omits the GSI partition key attribute entirely, DynamoDB bypasses the GSI write replication pipeline. The write to that GSI consumes **0 WCU**. This is why sparse indexes (Section 5) are an essential cost-control mechanism.
### 8.4 Capacity Modes: On-Demand vs. Provisioned Break-Even
| Dimension | On-Demand (`PAY_PER_REQUEST`) | Provisioned Mode (`PROVISIONED`) |
| :--- | :--- | :--- |
| **Pricing Baseline** | ~$1.25 / million write units<br>~$0.25 / million read units | ~$0.00065 / WCU-hr (~$0.47 / WCU-month)<br>~$0.00013 / RCU-hr (~$0.09 / RCU-month) |
| **Idle Cost** | $0.00 | Fixed hourly rate per provisioned capacity unit |
| **Throttling Risk** | Near-zero (auto-scales up to 2× previous peak immediately) | High if traffic spikes exceed provisioned limit + burst pool |
| **Break-Even Threshold** | Best when average utilization is **< 14%** | Cheaper (up to **5×–7×**) when sustained utilization is **> 18%** |
> [!TIP]
> **Production Rule of Thumb:** Start new services on **On-Demand** to baseline actual traffic patterns without guessing capacity or risking throttling. Once traffic profiles stabilize and daily average utilization exceeds 15–20% of peak, switch to **Provisioned Mode with Application Auto Scaling** (targeting 70% utilization) to slash the DynamoDB bill by 60%–75%.
### 8.5 Table Classes: Standard vs. Standard-IA
- **DynamoDB Standard:** Default storage class ($0.25/GB-month storage). Ideal for active workloads where read/write throughput dominates total cost.
- **DynamoDB Standard-IA (Infrequent Access):** $0.10/GB-month storage (**60% cheaper storage**), but read and write requests cost **25% more**.
- **Decision Rule:** Standard-IA reaches break-even when **storage accounts for more than 50% of the total monthly DynamoDB bill** (typical in audit logs, historical order archives, and compliance snapshots).
---
## Summary
| Concept | Rule |
| :--- | :--- |
| Design entry point | Enumerate queries first — every unanswered query revises the key design before adding a GSI |
| Composite sort keys | ISO date prefix enables chronological range queries without a GSI |
| One-to-many | Parent PK + child prefix under same partition → single Query for parent + children |
| Adjacency List | Two items per relationship (inverted PK/SK) + GSI for inverse direction |
| Sparse index | Only indexed items carry GSI key attributes — absent key = zero GSI write cost |
| GSI write amplification | (1 + GSI_count) × WCU per write — model before adding each GSI |
| LSI vs. GSI | Default to GSI; avoid LSI unless strongly consistent queries on alternate SK are mandatory AND partition size is guaranteed <10 GB |
| Cost taxonomy | 1 RCU = 4 KB read; 1 WCU = 1 KB write; Transactions = 2×; Co-located Queries aggregate item sizes |
| Capacity break-even | On-Demand for spiky/idle workloads (<14% avg); Provisioned + Auto Scaling for steady throughput (>18%) |
## What's Next
> In [Part 8: DynamoDB at Scale — Partition Internals, GSI Backpressure & ACID Transactions](/blog/aws-dynamodb-partition-mechanics-indexing-transactions), we go below the key schema to the physical storage model: why per-partition hard limits make table-level capacity scaling ineffective for hot partitions, how an under-provisioned GSI throttles base table writes upstream, and when ACID transactions are worth their 2× WCU cost.
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.