If you've come from relational databases, DynamoDB feels backwards at first. There's no schema, no joins, and the advice to put everything in one table seems insane. But once it clicks, you'll understand why DynamoDB is the right tool for high-scale, low-latency workloads.
In a relational database, you design your schema first and then build queries against it. In DynamoDB, you design your schema around your access patterns first. The table structure is a consequence of how your application reads data.
This is because DynamoDB only supports two kinds of reads:
That's it. No joins. No arbitrary filters without a full scan.
Every DynamoDB item has a primary key. It can be:
The partition key determines which physical partition stores your data. The sort key orders items within a partition and enables range queries.
Table: my-app
┌──────────────────┬──────────────────┬─────────────────┐
│ PK │ SK │ Attributes... │
├──────────────────┼──────────────────┼─────────────────┤
│ USER#user_01 │ PROFILE │ name, email... │
│ USER#user_01 │ ORDER#order_100 │ total, date... │
│ USER#user_01 │ ORDER#order_101 │ total, date... │
│ PRODUCT#prod_A │ META │ name, price... │
│ BLOG#my-slug │ META │ viewCount... │
│ BLOG#my-slug │ COMMENT#ulid_1 │ body, author... │
└──────────────────┴──────────────────┴─────────────────┘
By prefixing PKs with entity type (USER#, PRODUCT#, BLOG#), you can store multiple entity types in one table while keeping them logically separated.
In relational databases, you'd have a users table, an orders table, and a products table. In DynamoDB, the recommended practice is to put all your entities in one table.
Why? Because DynamoDB charges for reads, and fetching related data that lives in different tables requires multiple network round trips. With single-table design, you can fetch a user and all their recent orders in a single Query call.
Let's say you're building an e-commerce platform. Start by listing your access patterns:
Now design your keys to satisfy these patterns:
// Access Pattern 1: Get user by ID
// PK=USER#userId, SK=PROFILE
// Access Pattern 2: Get all orders for a user (newest first)
// PK=USER#userId, SK begins_with "ORDER#"
// Orders stored with SK=ORDER#{timestamp} — sorts chronologically
// Access Pattern 3: Get single order
// PK=USER#userId, SK=ORDER#{orderId}
// Access Pattern 4: Get all items in an order
// PK=ORDER#{orderId}, SK begins_with "ITEM#"// lib/dynamodb.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({ region: process.env.DYNAMODB_REGION });
const ddb = DynamoDBDocumentClient.from(client);
const TABLE = process.env.DYNAMODB_TABLE_NAME!;
export async function getUserWithOrders(userId: string) {
const { Items } = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "PK = :pk AND SK BETWEEN :start AND :end",
ExpressionAttributeValues: {
":pk": `USER#${userId}`,
":start": "ORDER#",
":end": "PROFILE",
},
}));
const profile = Items?.find(i => i.SK === "PROFILE");
const orders = Items?.filter(i => i.SK.startsWith("ORDER#")) ?? [];
return { profile, orders };
}One query, two entity types, zero extra network calls.
Your base table supports queries by PK. But what if you need to look up orders by status? That's where Global Secondary Indexes come in.
A GSI is essentially a separate table that DynamoDB maintains automatically, with a different PK/SK. You project the attributes you need.
GSI: StatusIndex
┌──────────────────┬──────────────┬─────────────────┐
│ GSI_PK (status) │ GSI_SK (date)│ Projected attrs │
├──────────────────┼──────────────┼─────────────────┤
│ PENDING │ 2026-05-01 │ orderId, userId │
│ SHIPPED │ 2026-05-02 │ orderId, userId │
│ DELIVERED │ 2026-04-30 │ orderId, userId │
└──────────────────┴──────────────┴─────────────────┘
GSIs cost extra read/write capacity, so use them deliberately — only for access patterns that aren't served by your base table.
1. Hot partitions — If many requests target the same PK, you'll exhaust that partition's throughput. Spread writes with partition sharding if needed.
2. Too many GSIs — Each GSI doubles your write cost for covered items. Design access patterns upfront to minimise GSIs.
3. Over-normalisation — Resist the urge to normalise like you would in SQL. Duplication is acceptable in DynamoDB if it serves a query pattern.
4. Missing the SK range query power — The most underused feature is begins_with and BETWEEN on sort keys. Design your SKs with composites (ORDER#2026-05-17#order_id) to enable time-range queries.
DynamoDB rewards upfront thinking. List your access patterns, design your keys to serve them, add GSIs only where needed, and embrace single-table design. Once you stop fighting the NoSQL mental model and lean into it, DynamoDB becomes one of the most powerful databases you can reach for at scale.