Mastering the DynamoDB service in LocalStack: creating tables with PK/SK, put, get, query, scan, LSI and GSI secondary indexes, throughput emulation limitations, and data modeling best practices.

In episode 5 we mastered S3 — object storage for files and media. Now we move to the other side that's just as important: DynamoDB, the NoSQL key-value and document database that backs many serverless architectures.
We touched on the fundamental difference with S3 in episode 0: S3 stores files, DynamoDB stores structured data accessed via primary key, query, and scan. In this episode we'll see how LocalStack emulates DynamoDB statefully — tables, items, and indexes are genuinely stored — and how proper data modeling patterns keep your applications fast and cost-efficient.
Every DynamoDB table must have a primary key. The primary key can be simple (a single partition key / HASH) or composite (partition key + sort key / RANGE). The composite model is the most flexible, because a single partition key can hold many items ordered by the sort key.
awslocal dynamodb create-table \
--table-name users \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUESTThe users table is created with pk (HASH) and sk (RANGE). With --billing-mode PAY_PER_REQUEST, we don't need to set capacity — the emulator ignores throughput limits, but the syntax remains valid for production.
The table isn't active right away — it takes a few seconds to reach ACTIVE status. Verify with:
awslocal dynamodb describe-table --table-name users --query 'Table.TableStatus'Once ACTIVE, the table is ready to accept items. Check the list of created tables with awslocal dynamodb list-tables.
Items in DynamoDB are written with typed attribute format (S for string, N for number, B for binary). Here's an example put-item and get-item:
awslocal dynamodb put-item --table-name users --item '{"pk": {"S": "user#1"}, "sk": {"S": "profile"}, "name": {"S": "Arman"}, "age": {"N": "30"}}'
awslocal dynamodb get-item --table-name users --key '{"pk": {"S": "user#1"}, "sk": {"S": "profile"}}'Notice the user#1 naming pattern on the partition key. This is part of the single table design we'll discuss later — it allows many entity types to live in one table.
This is the most important decision when using DynamoDB:
awslocal dynamodb query --table-name users --key-condition-expression "pk = :pk" --expression-attribute-values '{":pk": {"S": "user#1"}}'
awslocal dynamodb scan --table-name usersTip
Make scan your last resort. If you often need to filter by attributes other than the primary key, that's a sign you need a secondary index or a better key design.
A secondary index enables querying with additional access patterns without scanning. There are two types:
| Type | Basis | Notes |
|---|---|---|
| LSI (Local Secondary Index) | Alternative sort key on the same partition key | Defined when the table is created |
| GSI (Global Secondary Index) | New partition key and sort key | Can be added at any time |
Here's an example of creating a table with a GSI indexing customerId and status:
awslocal dynamodb create-table \
--table-name orders \
--attribute-definitions AttributeName=orderId,AttributeType=S AttributeName=customerId,AttributeType=S AttributeName=status,AttributeType=S \
--key-schema AttributeName=orderId,KeyType=HASH \
--global-secondary-indexes 'IndexName=ByCustomer,KeySchema=[{AttributeName=customerId,KeyType=HASH},{AttributeName=status,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--billing-mode PAY_PER_REQUESTOnce the GSI is active, querying by customerId becomes fast:
awslocal dynamodb query --table-name orders --index-name ByCustomer --key-condition-expression "customerId = :c" --expression-attribute-values '{":c": {"S": "cust-1"}}'On real AWS, item access is measured in capacity units: provisioned (WCU/RCU) or on-demand (pay per request). LocalStack ignores throughput limits — every access is considered successful without accounting for capacity. That's nice for development, but there are consequences:
So stay disciplined with efficient access patterns even though the emulator doesn't punish you.
Two principles have the most impact:
user#1 and order#1. A single query can fetch many types of related data, reducing the number of tables and round-trips.active) creates one hot partition. Use high-cardinality values, such as user IDs.| Principle | Why |
|---|---|
| Design follows access patterns | Tables are optimized for the queries actually used |
| Avoid scans | Scans are expensive in production; use keys or indexes |
| One entity, one key pattern | Single table design simplifies combined queries |
create-table (PK/SK), put-item, get-item, query, and scan — prefer query, avoid scan.You've now mastered two core services. In the next episode, episode 7, we step up to the next level: creating and running Lambda functions in LocalStack — from create-function and invoke, to triggering them from S3 and SQS events. See you in the next episode!