Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 042 / 100

DynamoDB fundamentals: a practical guide for developers

Design for access patterns, not entities.

Expona Admin7 min read

When I first moved a relational database workload to DynamoDB, I treated it like SQL—thinking about entities and normalization. That was a mistake. DynamoDB fundamentals aren't about designing tables the way you'd design a relational schema. They're about obsessing over your access patterns and building a schema that makes queries fast and cheap.

In this article, I'll walk you through the core concepts: partition keys, sort keys, access patterns, global secondary indexes, and capacity modes. By the end, you'll know how to design a DynamoDB table that actually performs in production.

Partition and Sort Keys — Queries must use the key. Scans are expensive.

Every DynamoDB table has a partition key (also called hash key). This is the primary way DynamoDB distributes data across partitions. When you query by partition key, DynamoDB knows exactly which partition to hit. When you don't, it has to scan the entire table—and scanning is brutal for performance and cost.

The partition key alone gives you equality queries: "Get me all items where user_id = 123." But most real-world access patterns need ordering or range filtering. That's where the sort key comes in.

A sort key (also called range key) lets you query a range of items within a partition. Together, partition key + sort key form the primary key and must be unique across the table.

Real example: I built a ride-hailing platform where I needed to query rides by user and creation date. The table had:

  • Partition key: user_id
  • Sort key: created_at (timestamp)

This let me query "Give me all rides for user_id = 'user-456' created in the last 7 days" using a range query on the sort key. Without the sort key, I'd have to fetch all rides for that user and filter in application code.

Here's what that query looks like:

bash
const result = await dynamodb.query({
  TableName: 'Rides',
  KeyConditionExpression: 'user_id = :uid AND created_at BETWEEN :start AND :end',
  ExpressionAttributeValues: {
    ':uid': 'user-456',
    ':start': 1700000000,
    ':end': 1700604800
  }
}).promise();

Compare that to a full table scan, which would read every single ride in the database:

bash
// Expensive. Don't do this.
const result = await dynamodb.scan({
  TableName: 'Rides'
}).promise();

Scans consume read capacity proportional to every item examined, not just the items returned. If your table has 10 million rides and you scan for 100, you pay for 10 million reads. Queries, by contrast, only charge you for the items you retrieve.

Access Patterns First — List every query before designing tables.

This is the mindset shift. Don't start with entities. Start with queries.

In SQL, you normalize tables and then write queries against them. In DynamoDB, you define every access pattern your application needs, then design the schema to support those patterns efficiently. If you skip this step, you'll end up with tables that require scans, and you'll be wondering why your costs are exploding.

I always create a document listing all access patterns before I touch the schema. For an e-commerce platform I worked on, that document looked like:

  • Get an order by order_id
  • Get all orders for a customer, sorted by date (newest first)
  • Get all orders with status = 'pending', sorted by date
  • Get inventory for a product
  • Get all products in a category, sorted by popularity

Now I design tables that support these patterns efficiently.

Orders table: partition key = customer_id, sort key = order_date. This supports "all orders for a customer, sorted by date."

Orders by status table (or GSI): partition key = order_status, sort key = order_date. This supports "all pending orders, sorted by date."

Inventory table: partition key = product_id, sort key = warehouse_id (or no sort key if you just store total inventory). This supports "get inventory for a product."

Products by category table (or GSI): partition key = category, sort key = popularity_score. This supports "all products in a category, sorted by popularity."

Without this upfront planning, you end up trying to query the table in ways it wasn't designed for, forcing scans or application-level filtering. That's expensive and slow.

GSIs — Secondary Indexes for Alternative Access Patterns.

A Global Secondary Index (GSI) is a separate index on your table with its own partition key and sort key. It lets you query data by a different key than the main table.

Think of a GSI as "a different lens on your data." The main table is your primary lens; a GSI is an alternative one.

In my e-commerce example, the Orders table had partition key = customer_id, sort key = order_date. That's great for querying orders by customer. But what if I need to find all pending orders across all customers, sorted by date? That requires a GSI.

GSI on the Orders table:

  • Partition key: order_status
  • Sort key: order_date

Now I can query:

bash
const result = await dynamodb.query({
  TableName: 'Orders',
  IndexName: 'StatusDateIndex',
  KeyConditionExpression: 'order_status = :status AND order_date > :cutoff',
  ExpressionAttributeValues: {
    ':status': 'pending',
    ':cutoff': 1700000000
  }
}).promise();

A few important notes on GSIs:

  • GSIs are eventually consistent by default. Changes to the main table take time to propagate. If you need strong consistency, you must query the main table.
  • GSIs consume their own read and write capacity (if using provisioned mode). You must allocate capacity to each GSI separately.
  • GSIs can be sparse. If an item doesn't have the GSI's partition key attribute, it won't appear in the index. This is useful for filtering.
  • You can have up to 10 GSIs per table.

I've found GSIs invaluable for supporting multiple access patterns without denormalizing data or duplicating it across multiple tables. But they come with a tradeoff: extra storage, eventual consistency, and additional capacity costs.

Capacity Modes — On-demand for unpredictable traffic, provisioned for steady loads.

DynamoDB offers two billing models: on-demand and provisioned.

On-demand mode is pay-per-request. You don't pre-allocate capacity. DynamoDB scales automatically, and you pay for every read and write unit consumed. It's great for unpredictable or bursty traffic. The tradeoff is cost—on-demand is more expensive per unit than provisioned if you have steady, predictable load.

Provisioned mode requires you to specify read and write capacity (in capacity units) upfront. DynamoDB reserves that capacity for you. If you stay within your provisioned limits, the cost is lower. If you exceed them, requests are throttled. It's best for predictable workloads where you can forecast capacity needs.

My typical approach:

  • Early-stage or prototype: Use on-demand. You don't know traffic patterns yet, and you don't want to manage capacity.
  • Proven, steady-state production workload: Move to provisioned and right-size based on metrics. This saves money.
  • Unpredictable or seasonal: Stay on on-demand, or use provisioned with auto-scaling (though auto-scaling has a lag).

For the ride-hailing platform, I started with on-demand for the Rides table because traffic was erratic—quiet late at night, spikes during peak hours. Moving to provisioned would have required either over-provisioning (wasting money) or getting throttled (bad UX).

For the Orders table in e-commerce, traffic was predictable: roughly steady throughout the day, with a known peak around lunch and evening. There I used provisioned capacity and auto-scaling rules, which brought costs down significantly.

To estimate provisioned capacity, divide your expected requests per second by 4,000 (for reads) or 1,000 (for writes). A table expecting 2,000 reads per second needs 2,000 / 4,000 = 0.5 read capacity units (RCUs) provisioned. In practice, I provision a buffer (usually 20–30% extra) to handle traffic spikes.

Common Mistakes

Designing tables like relational databases. I see this constantly. Developers create a Users table, Orders table, Products table—normalized, relational structure—and then wonder why queries are slow. DynamoDB rewards denormalization. If you need user data alongside order data in responses, denormalize it into the Orders table (or a GSI). Yes, you'll store user data multiple times. That's okay in DynamoDB.

Querying without the partition key. If you query only by sort key or non-key attributes, DynamoDB has to scan. Every time. That's expensive. Always include the partition key in your query condition.

Ignoring access patterns during design. You sit down, sketch a schema, and start coding. Three weeks later, you need a query you didn't anticipate, and it requires a full table scan. Plan access patterns upfront.

Creating too many GSIs. Each GSI consumes storage and capacity. I've seen tables with 8 or 10 GSIs, most unused or queried rarely. Be selective. If a GSI is queried infrequently, consider materializing the data in application code or using a different tool (like Elasticsearch for complex searches).

Not monitoring capacity in provisioned mode. Set up CloudWatch alarms for throttling. If you're consistently hitting your provisioned limits, increase capacity or switch to on-demand.

Key Takeaways

  • DynamoDB is built for fast queries on partition and sort keys. Scans are expensive; avoid them in production.
  • Design your schema around access patterns, not entities. List every query you need before touching the schema.
  • Use GSIs for alternative access patterns. They enable multiple query patterns on a single table without scanning.
  • Choose on-demand for unpredictable or bursty traffic; choose provisioned for steady, predictable workloads where you can save on cost.
  • Denormalization is not a sin in DynamoDB. If it makes queries faster and cheaper, do it.
  • Always include the partition key in your queries. Without it, you're scanning.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles