Creating a DynamoDB table takes one command. Getting it right matters more than usual because the key schema can never be changed, no migration, no ALTER TABLE. Changing a partition key means creating a new table and copying every item across.
Everything else, capacity mode, indexes, streams, TTL, backups, can be changed later. So the decision worth slowing down for is the keys, and that decision comes from your access patterns rather than from the shape of your data.
aws dynamodb create-table --table-name Orders \
--attribute-definitions \
AttributeName=customerId,AttributeType=S \
AttributeName=orderDate,AttributeType=S \
--key-schema \
AttributeName=customerId,KeyType=HASH \
AttributeName=orderDate,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--deletion-protection-enabled \
--tags Key=Environment,Value=productionaws dynamodb wait table-exists --table-name Orders
aws dynamodb describe-table --table-name Orders \
--query 'Table.[TableStatus,ItemCount,TableSizeBytes,BillingModeSummary.BillingMode]'[ "ACTIVE", 0, 0, "PAY_PER_REQUEST" ]ItemCount and TableSizeBytes update roughly every six hours, so a freshly loaded table reporting zero items is normal rather than a failed import.
This trips up nearly everyone coming from SQL. --attribute-definitions is not a schema. It declares the types of attributes used in keys and indexes, and nothing else. Every other attribute is arbitrary and per-item:
aws dynamodb put-item --table-name Orders --item '{
"customerId": {"S": "C-4821"},
"orderDate": {"S": "2026-09-16"},
"amount": {"N": "149.99"},
"items": {"L": [{"S": "SKU-1"}, {"S": "SKU-2"}]},
"giftNote": {"S": "Happy birthday"}
}'Nothing declared amount, items or giftNote, and the next item need not have them. The corollary is that declaring an attribute you do not use in a key is an error:
ValidationException: One or more parameter values were invalid:
Number of attributes in KeySchema does not exactly match number of
attributes defined in AttributeDefinitions
Only three key types exist: S (string), N (number) and B (binary).
aws dynamodb update-table --table-name Orders --deletion-protection-enabledA delete-table call is immediate and takes every item with it. Deletion protection costs nothing, and it is the cheapest insurance in DynamoDB. Set it on every table that is not disposable, including in your Terraform or CloudFormation so a destroy in the wrong workspace fails loudly.
Point-in-time recovery gives continuous backup with second-level granularity for the last 35 days:
aws dynamodb update-continuous-backups --table-name Orders \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=trueaws dynamodb restore-table-to-point-in-time \
--source-table-name Orders \
--target-table-name Orders-restored \
--restore-date-time 2026-09-16T09:00:00ZOn-demand backups are explicit snapshots that live until you delete them, which is what you want before a risky migration or for retention beyond 35 days:
aws dynamodb create-backup --table-name Orders --backup-name pre-migration-2026-09-16
aws dynamodb list-backups --table-name Orders --query 'BackupSummaries[].[BackupName,BackupStatus]'The constraint that surprises people: a restore always creates a new table. You cannot restore in place. Recovery therefore means restoring alongside, verifying, then repointing the application, so practise it before you need it, and note that indexes, TTL, streams and capacity settings are not all carried over automatically.
aws dynamodb update-table --table-name AuditLog \
--table-class STANDARD_INFREQUENT_ACCESSSTANDARD_INFREQUENT_ACCESS cuts storage cost substantially and raises read and write cost. It pays off when storage dominates the bill. Audit logs, historical events, anything large and rarely read. For a table serving live traffic it usually costs more. Compare TableSizeBytes against consumed capacity before switching.
Multi-region, multi-writer replication:
aws dynamodb update-table --table-name Orders \
--replica-updates '[{"Create": {"RegionName": "us-east-1"}}]'Replication is asynchronous and conflicts resolve last writer wins, by the timestamp DynamoDB records. That is fine for data owned by one region at a time and wrong for anything where two regions might update the same item concurrently, a counter, an inventory level, because one update is simply discarded. Streams must be enabled, and the same key schema applies to every replica.
aws dynamodb import-table \
--s3-bucket-source S3Bucket=my-imports,S3KeyPrefix=orders/ \
--input-format DYNAMODB_JSON \
--table-creation-parameters '{
"TableName":"OrdersImported",
"AttributeDefinitions":[{"AttributeName":"customerId","AttributeType":"S"}],
"KeySchema":[{"AttributeName":"customerId","KeyType":"HASH"}],
"BillingMode":"PAY_PER_REQUEST"
}'Import from S3 consumes no write capacity and is dramatically cheaper than looping put-item, but it can only create a new table, not add to an existing one. Export to S3 similarly consumes no read capacity and is the right way to feed Athena or a data pipeline:
aws dynamodb export-table-to-point-in-time \
--table-arn "$TABLE_ARN" --s3-bucket my-exports --export-format DYNAMODB_JSONExport requires PITR to be enabled.
ResourceInUseException: Table already exists: Orders
create-table is not idempotent. Check with describe-table first.
ValidationException: Number of attributes in KeySchema does not exactly match
number of attributes defined in AttributeDefinitions
You declared an attribute that is not part of a key or index, or omitted one that is.
ResourceNotFoundException: Requested resource not found
Right table name, wrong region. The most common cause by some distance. DynamoDB tables are regional.
ValidationException: Table is protected against deletion
Deletion protection is on. That is it working.
| Task | Command |
|---|---|
| Create | aws dynamodb create-table ⦠|
| Wait until ready | aws dynamodb wait table-exists --table-name <t> |
| Inspect | aws dynamodb describe-table --table-name <t> |
| List | aws dynamodb list-tables |
| Protect | --deletion-protection-enabled |
| Enable PITR | aws dynamodb update-continuous-backups ⦠|
| Snapshot | aws dynamodb create-backup --backup-name <n> |
| Restore | aws dynamodb restore-table-to-point-in-time ⦠|
| Add a replica | --replica-updates '[{"Create":{"RegionName":"â¦"}}]' |
| Bulk load | aws dynamodb import-table ⦠|
delete-table is instant.<MCQExercise exercise={{ id: 'aws-ddb-tables-quiz1', title: 'A create-table call that keeps failing', description: 'A create-table command declares attribute definitions for customerId, orderDate and amount, with customerId as HASH and orderDate as RANGE. It fails validation. Why?', options: [ { id: 'a', text: 'AttributeDefinitions may only contain attributes used in the key schema or an index, and amount is neither' }, { id: 'b', text: 'Numeric attributes cannot be declared in AttributeDefinitions' }, { id: 'c', text: 'A table may define at most two attributes in total' }, { id: 'd', text: 'amount must also be added to the key schema as a second RANGE key' }, ], correctId: 'a', explanation: 'AttributeDefinitions is not a schema. DynamoDB is schemaless apart from keys. Its only job is to declare the types of attributes that appear in the key schema or in an index, so every entry must be used by one of those. Declaring amount, which is an ordinary attribute, makes the definitions and the key schema disagree and the call is rejected. Remove it: you can still write amount on any item, with any type, without declaring it anywhere.', wrongExplanations: { b: 'Numbers are a valid key type (N). The problem is that this attribute is not a key at all.', c: 'There is no such limit; a table with several indexes legitimately declares many attributes.', d: 'DynamoDB supports exactly one partition key and at most one sort key. A second RANGE key is not possible.', }, }} />
<MCQExercise exercise={{ id: 'aws-ddb-tables-quiz2', title: 'Recovering from a bad write', description: 'A faulty deployment corrupted records over two hours. The table has point-in-time recovery enabled. What does recovery actually involve?', options: [ { id: 'a', text: 'Restore to a new table at a timestamp before the deployment, verify it, then repoint the application. PITR cannot restore in place' }, { id: 'b', text: 'Run restore-table-to-point-in-time against the existing table to roll it back in place' }, { id: 'c', text: 'Replay DynamoDB Streams in reverse to undo the writes' }, { id: 'd', text: 'Nothing is possible, because PITR only protects against table deletion' }, ], correctId: 'a', explanation: 'PITR restores to any second within the last 35 days, but always into a new table. There is no in-place rollback. So recovery is a three-step operation: restore alongside the live table, verify the data is what you expect, then cut the application over. That is worth rehearsing before an incident, partly for the cutover and partly because a restored table does not automatically carry across every setting: check indexes, TTL, streams, capacity mode and deletion protection afterwards.', wrongExplanations: { b: 'The API requires a target table name distinct from the source. In-place restore does not exist.', c: 'Streams retain only 24 hours of changes and are not designed for reversal; replaying them backwards is not a supported operation.', d: 'PITR protects against any data loss within the window, including bad writes. Deletion protection is the separate feature that guards against dropping the table.', }, }} />
AttributeDefinitions declares the data types of attributes that participate in the key schema or an index, and nothing more. Everything else is schemaless: two items in the same table can carry completely different attributes, and nothing needs declaring in advance. That is also why declaring an attribute you do not use in a key causes a validation error. The definitions list and the key schema must correspond exactly.
PITR is continuous: once enabled, you can restore to any second within the last 35 days, which is what you want for recovering from a bad deployment. On-demand backups are explicit snapshots that persist until you delete them, so they cover longer retention and deliberate checkpoints such as "before this migration". They are billed differently and serve different purposes; most production tables want PITR always on, plus on-demand backups before risky changes.
Because ItemCount and TableSizeBytes are updated roughly every six hours rather than in real time. A freshly loaded table legitimately reports zero, and that is not a sign the import failed. For an accurate current count you would have to scan the table with --select COUNT, which consumes read capacity proportional to the table size, usually not worth it just to confirm a load succeeded.
Only if you understand the conflict behaviour. Global tables replicate asynchronously and resolve conflicts last-writer-wins, so if two regions update the same item at nearly the same moment, one update is silently discarded. That is fine when each item is effectively owned by one region, user data pinned to a home region, for example, and wrong for counters, inventory levels or anything where a lost update matters. Where it fits, it gives genuine multi-region writes with very little work.
Reducing storage cost for data that is rarely read, at the price of higher per-request read and write cost. It suits audit logs, historical events and archives. Tables that are large relative to their traffic. For a table serving live application traffic the extra request cost usually outweighs the storage saving, so compare TableSizeBytes against your consumed capacity before switching. The change is a single update-table call and is reversible.
Learn DynamoDB Query vs Scan, why filter expressions do not cut cost, how GSIs differ from LSIs, and how to paginate results and use sparse indexes.
Run ElastiCache well: pick Redis, Valkey or Memcached, start serverless, set eviction policies, implement cache-aside and handle cluster mode and failover.
Use Amazon SQS well: Standard vs FIFO, a visibility timeout longer than processing time, long polling, idempotent consumers, dead-letter queues, monitoring.