8 min

Amazon DynamoDB Explained: Building Scalable Systems

Learn what Amazon DynamoDB is, how its NoSQL model works, and practical design patterns for scalable, low-latency systems and microservices.

Amazon DynamoDB Explained: Building Scalable Systems

What DynamoDB Is and Why Teams Use It

Amazon DynamoDB is a fully managed NoSQL database service from AWS, designed for applications that need consistently low-latency reads and writes at virtually any scale. “Fully managed” means AWS handles infrastructure work—hardware provisioning, replication, patching, and many operational tasks—so teams can focus on shipping features instead of running database servers.

At its core, DynamoDB stores data as items (rows) inside tables, but each item can have flexible attributes. The data model is best understood as a mix of:

  • Key-value: fetch an item quickly by its primary key, much like looking up a record by an ID.
  • Document: store nested attributes (maps and lists), similar to JSON, which is useful for related fields without strict schemas.

Teams choose DynamoDB when they want predictable performance and simpler operations for workloads that don’t fit neatly into relational joins. It’s commonly used for microservices (each service owning its data), serverless apps with bursty traffic, and event-driven systems that react to changes in data.

This post walks through the building blocks (tables, keys, and indexes), how to model around access patterns (including single-table design), how scaling and capacity modes work, and practical patterns for streaming changes into an event-driven architecture.

Core Concepts: Tables, Items, and Primary Keys

DynamoDB is organized around a few simple building blocks, but the details matter because they determine how you model data and how fast (and cost-effective) requests will be.

Tables, items, and attributes

A table is the top-level container. Each record in a table is an item (similar to a row), and each item is a set of attributes (similar to columns).

Unlike relational databases, items in the same table don’t need to share the same attributes. One item might have {status, total, customerId}, while another includes {status, shipmentTracking}—DynamoDB doesn’t require a fixed schema.

Primary keys: simple vs. composite

Every item is uniquely identified by a primary key, and DynamoDB supports two types:

  • Simple primary key (partition key only): one attribute uniquely identifies each item.
  • Composite primary key (partition key + sort key): multiple items can share the same partition key, while the sort key distinguishes them and defines order within that partition.

In practice, composite keys enable “grouped” access patterns like “all orders for a customer, newest first.”

Query vs. scan (high level)

A Query reads items by primary key (or an index key). It targets a specific partition key and can filter by sort key ranges—this is the efficient, preferred path.

A Scan walks the whole table (or index) and then filters. It’s easy to start with, but it’s usually slower and more expensive at scale.

Limits to keep in mind

A few constraints you’ll feel early:

  • Max item size: 400 KB.
  • Attribute types: scalars (string/number/binary/boolean/null), sets, lists, and maps.
  • Key attributes must be scalar (no lists/maps as partition or sort keys).

These fundamentals set up everything that follows: access patterns, indexing choices, and performance characteristics.

DynamoDB’s Data Model: Key-Value and Document

DynamoDB is often described as both a key-value store and a document database. That’s accurate, but it helps to understand what each implies in day-to-day design.

Key-value access vs. document-style items

At its core, you retrieve data by key. Provide the primary key values and DynamoDB returns a single item. That keyed lookup is what delivers predictable, low-latency storage for many workloads.

At the same time, an item can contain nested attributes (maps and lists), which makes it feel like a document database: you can store structured payloads without defining a rigid schema upfront.

Modeling hierarchical JSON-like structures in items

Items map naturally to JSON-like data:

  • Maps represent objects (e.g., profile.name, profile.address).
  • Lists represent arrays (e.g., recent actions, tags).

This is a great fit when an entity is usually read as a whole—like a user profile, a shopping cart, or a configuration bundle.

When to denormalize (and why it’s common)

DynamoDB doesn’t support server-side joins. If your app needs to fetch “an order plus its line items plus shipping status” in one read path, you’ll often denormalize: copy some attributes into multiple items, or embed small substructures directly inside an item.

Trade-offs vs. relational normalization

Denormalization increases write complexity and can create update fan-out. The payoff is fewer round trips and faster reads—often the critical path in scalable systems.

Partition Key and Sort Key: Designing for Access Patterns

The fastest DynamoDB queries are the ones you can express as “give me this partition” (and optionally “within this partition, give me this range”). That’s why key choice is primarily about how you read data, not just how you store it.

Partition key: distribution and predictable reads

The partition key determines which physical partition stores an item. DynamoDB hashes this value to spread data and traffic. If many requests concentrate on a small set of partition key values, you can create “hot” partitions and hit throughput limits even if the table is mostly idle.

Good partition keys:

  • Have high cardinality (many distinct values)
  • Match a frequent access pattern (so reads are direct, not filtered)
  • Avoid values that become “popular” (e.g., a constant like "GLOBAL")

Sort key: range queries and grouped entities

With a sort key, items sharing the same partition key are stored together and ordered by the sort key. This enables efficient:

  • Range queries (BETWEEN, begins_with)
  • Time-ordered reads (latest-first with reverse scans)
  • Entity grouping (multiple item types under one partition key)

A common pattern is composing the sort key, such as TYPE#id or TS#2025-12-22T10:00:00Z, to support multiple query shapes without extra tables.

Mapping common access patterns to keys

  • Get by ID: PK = USER#<id> (simple GetItem)
  • List by user: PK = USER#<id>, SK begins_with ORDER# (or SK = CREATED_AT#...)
  • Time-series ranges: PK = DEVICE#<id>, SK = TS#<timestamp> with BETWEEN for time windows

How key choice affects performance and scalability

If your partition key aligns with your highest-volume queries and distributes evenly, you get consistently low-latency reads and writes. If it doesn’t, you’ll compensate with scans, filters, or extra indexes—each adding cost and increasing the risk of hot keys.

Secondary Indexes: GSI and LSI Explained

Secondary indexes give DynamoDB alternate query paths beyond your table’s primary key. Instead of reshaping your base table every time a new access pattern appears, you can add an index that re-keys the same items for a different query.

GSI vs. LSI: what’s the difference?

A Global Secondary Index (GSI) has its own partition key (and optional sort key) that can be completely different from the table’s. It’s “global” because it spans all table partitions and can be added or removed at any time. Use a GSI when you need a new access pattern that doesn’t fit the original key design—for example, querying orders by customerId when the table is keyed by orderId.

A Local Secondary Index (LSI) shares the same partition key as the base table but uses a different sort key. LSIs must be defined at table creation. They’re useful when you want multiple sort orders within the same entity group (same partition key), like fetching a customer’s orders sorted by createdAt vs. status.

Projections: what gets copied into the index

Projection determines which attributes DynamoDB stores in the index:

  • KEYS_ONLY: cheapest storage, but you’ll often need an extra read from the base table.
  • INCLUDE: copy only the attributes you commonly return.
  • ALL: simplest, but can inflate storage and write cost.

Write amplification (the hidden bill)

Every write to the base table can trigger writes to one or more indexes. More GSIs plus wider projections increases write costs and capacity consumption. Plan indexes around stable access patterns, and keep projected attributes minimal when possible.

Capacity Modes and Scaling Behavior

From model to running demo
Turn your access patterns into a working React plus Go API in minutes with Koder.ai.

DynamoDB scaling starts with a choice: On-Demand or Provisioned capacity. Both can reach very high throughput, but they behave differently under changing traffic.

On-Demand vs. Provisioned: how to choose

On-Demand is the simplest: you pay per request and DynamoDB automatically accommodates variable load. It’s a good fit for unpredictable traffic, early-stage products, and spiky workloads where you don’t want to manage capacity targets.

Provisioned is capacity planning: you specify (or auto-scale) read and write throughput and get more predictable pricing at steady usage. It’s often cheaper for known, consistent workloads and for teams that can forecast demand.

Read/write capacity in practice

Provisioned throughput is measured in:

  • RCUs (Read Capacity Units): roughly one strongly consistent read per second for up to 4 KB (or two eventually consistent reads).
  • WCUs (Write Capacity Units): roughly one write per second for up to 1 KB.

Your item size and access pattern determine real cost: larger items, strong consistency, and scans can burn capacity quickly.

Auto scaling basics (and limits)

Auto scaling adjusts provisioned RCUs/WCUs based on utilization targets. It helps with gradual growth and predictable cycles, but it’s not instant. Sudden spikes can still throttle if capacity doesn’t ramp fast enough, and it can’t fix a hot partition key that concentrates traffic on one partition.

DAX: caching for read-heavy workloads

DynamoDB Accelerator (DAX) is an in-memory cache that can reduce read latency and offload repeated reads (e.g., popular product pages, session lookups, leaderboards). It’s most useful when many clients repeatedly request the same items; it won’t help write-heavy patterns, and it doesn’t replace careful key design.

Consistency, Transactions, and Correctness

DynamoDB lets you trade off read guarantees against latency and cost, so it’s important to be explicit about what “correct” means for each operation.

Eventually consistent vs. strongly consistent reads

By default, GetItem and Query use eventually consistent reads: you might briefly see an older value right after a write. This is often fine for feeds, product catalogs, and other read-mostly views.

With strongly consistent reads (an option for reads from the base table in a single region), DynamoDB guarantees you see the latest acknowledged write. Strong consistency costs more read capacity and can increase tail latency, so reserve it for truly critical reads.

When strong consistency matters

Strong consistency is valuable for reads that gate irreversible actions:

  • Checking available inventory before confirming an order
  • Reading an authorization flag before granting access
  • Fetching the current state of a workflow before executing the next step

For counters, the safest approach is typically not “strong read then write,” but an atomic update (e.g., UpdateItem with ADD) so increments aren’t lost.

Transactional reads/writes

DynamoDB transactions (TransactWriteItems, TransactGetItems) provide ACID semantics across up to 25 items. They’re useful when you must update multiple items together—like writing an order and reserving inventory—or enforcing invariants that can’t tolerate intermediate states.

Idempotency for safe retries

Retries are normal in distributed systems. Make writes idempotent so retries don’t duplicate effects:

  • Use a client request token (idempotency key) stored with the result
  • Enforce uniqueness with ConditionExpression (e.g., “only create if attribute_not_exists”)
  • Prefer atomic updates over read-modify-write loops

Correctness in DynamoDB is mostly about choosing the right consistency level and designing operations so retries can’t break your data.

Partitions, Hot Keys, and Traffic Spikes

DynamoDB stores table data across multiple physical partitions. Each partition has finite throughput for reads and writes, plus a limit on how much data it can hold. Your partition key determines where an item lives; if too many requests target the same partition key value (or a small set of values), that partition becomes the bottleneck.

Why hot partitions happen

Hot partitions are usually caused by key choices that concentrate traffic: a “global” partition key like USER#1, TENANT#default, or STATUS#OPEN, or time-ordered patterns where everyone writes to “now” under one partition key.

Symptoms of hot keys and uneven traffic

You’ll typically see:

  • Throttling (ProvisionedThroughputExceededException) for a subset of keys
  • Elevated and spiky latency for a few access patterns while others remain fast
  • CloudWatch metrics showing uneven consumed capacity and sudden bursts

Mitigation techniques

Design for distribution first, then query convenience:

  • Key design: ensure high-cardinality partition keys (e.g., TENANT#<id> instead of a shared constant).
  • Write sharding: add a small random or hashed suffix/prefix such as ORDER#<id>#<shard> to spread writes across N shards, then query across shards when needed.
  • Time buckets: bucket by hour/day (METRIC#2025-12-22T10) to prevent “all writes go to the latest item.”

Handling bursty workloads

For unpredictable spikes, on-demand capacity can absorb bursts (within service limits). With provisioned capacity, use auto scaling and implement client-side exponential backoff with jitter on throttles to avoid synchronized retries that amplify the spike.

Data Modeling Patterns for Scalable Systems

Share a live test environment
Deploy your prototype so teammates can test hot-key scenarios with real traffic patterns.

DynamoDB data modeling starts from access patterns, not from ER diagrams. You design keys so the queries you need become fast Query operations, while everything else is either avoided or handled asynchronously.

Single-table design (and why teams like it)

“Single-table design” means storing multiple entity types (users, orders, messages) in one table and using consistent key conventions to fetch related data in a single Query. This reduces cross-entity round trips and keeps latency predictable.

A common approach is composite keys:

  • PK groups a logical partition (e.g., USER#123)
  • SK orders items within that group (e.g., PROFILE, ORDER#2025-12-01, MSG#000123)

This lets you fetch “everything for a user” or “only orders for a user” by choosing a sort-key prefix.

Relationships: adjacency lists and many-to-many

For graph-like relationships, an adjacency list works well: store edges as items.

  • PK = USER#123, SK = FOLLOWS#USER#456

To support reverse lookups or true many-to-many, add an inverted edge item or project to a GSI, depending on read paths.

Time-series: bucket + sort key + TTL

For events and metrics, avoid unbounded partitions by bucketing:

  • PK = DEVICE#9#2025-12-22 (device + day)
  • SK = TS#1734825600 (timestamp)

Use TTL to expire old points automatically, and keep aggregates (hourly/daily rollups) as separate items for fast dashboards.

If you want a deeper refresher on key conventions, see /blog/partition-key-and-sort-key-design.

Streams and Event-Driven Architectures

DynamoDB Streams is DynamoDB’s built-in change data capture (CDC) feed. When enabled on a table, every insert, update, or delete produces a stream record that downstream consumers can react to—without polling the table.

DynamoDB Streams fundamentals

A stream record contains keys and (optionally) the item’s old and/or new image, depending on the stream view type you choose (keys only, new image, old image, both). Records are grouped into shards, which you read sequentially.

Building event-driven workflows

A common setup is DynamoDB Streams → AWS Lambda, where each batch of records triggers a function. Other consumers are possible too (custom consumers, or piping into analytics/logging systems).

Typical workflows include:

  • Materialized views: write a denormalized read model table when the source table changes.
  • Cache invalidation: expire or refresh items in Redis/ElastiCache after writes.
  • Audit logs: append immutable change events to an audit table or external store.

This keeps the primary table optimized for low-latency reads/writes while pushing derived work to asynchronous consumers.

Ordering, retries, and correctness

Streams provide ordered processing per shard (which typically correlates with the partition key), but there is no global ordering across all keys. Delivery is at-least-once, so duplicates can happen.

To handle this safely:

  • Make handlers idempotent (e.g., upsert by primary key, use conditional writes, or store processed event IDs).
  • Expect retries and partial batch failures; use DLQs/on-failure destinations where possible.
  • Keep side effects (emails, payments) behind deduplication or transactional safeguards.

Designed with these guarantees in mind, Streams can turn DynamoDB into a solid backbone for event-driven systems.

Reliability, Backups, and Observability

DynamoDB is designed for high availability by spreading data across multiple Availability Zones within a region. For most teams, the practical reliability wins come from having a clear backup strategy, understanding replication options, and monitoring the right metrics.

Backups: on-demand vs. point-in-time recovery

On-demand backups are manual (or automated) snapshots you take when you want a known restore point—before a migration, after a release, or prior to a big backfill. They’re great for “bookmark” moments.

Point-in-time recovery (PITR) continuously captures changes so you can restore the table to any second within the retention window. PITR is the safety net for accidental deletes, bad deploys, or malformed writes that slip past validation.

Replication and multi-region options

If you need multi-region resilience or low-latency reads near users, Global Tables replicate data across selected regions. They simplify failover planning, but introduce cross-region replication delay and conflict-resolution considerations—so keep write patterns and item ownership clear.

Monitoring essentials

At minimum, alert on:

  • Latency (p95/p99) for reads and writes
  • Throttled requests and system errors
  • Consumed capacity (and headroom vs. provisioned)

These signals usually surface hot-partition issues, insufficient capacity, or unexpected access patterns.

Incident playbooks

For throttling, first identify the access pattern causing it, then mitigate by temporarily switching to on-demand or increasing provisioned capacity, and consider sharding hot keys.

For partial outages or elevated errors, reduce blast radius: disable non-critical traffic, retry with jittered backoff, and fail gracefully (for example, serving cached reads) until the table stabilizes.

Security and Access Control

Get credits for sharing
Create content about your build and earn credits to keep experimenting on Koder.ai.

DynamoDB security is mostly about tightening who can call which API actions, from where, and on what keys. Because tables can hold many entity types (and sometimes many tenants), access control should be designed alongside the data model.

IAM permissions: least privilege by default

Start with identity-based IAM policies that limit actions (for example, dynamodb:GetItem, Query, PutItem) to the minimum set and scope them to specific table ARNs.

For finer control, use dynamodb:LeadingKeys to restrict access by partition key values—useful when a service or tenant should only read/write items in its own keyspace.

Encryption: what to verify

DynamoDB encrypts data at rest by default using AWS owned keys or a customer-managed KMS key. If you have compliance requirements, verify:

  • The table is configured with the intended KMS key
  • The calling role has the required KMS permissions (and nothing extra)

For encryption in transit, ensure clients use HTTPS (AWS SDKs do by default). If you terminate TLS in a proxy, confirm the hop between the proxy and DynamoDB is still encrypted.

Network controls: reduce exfiltration paths

Use a VPC Gateway Endpoint for DynamoDB so traffic stays on the AWS network and you can apply endpoint policies to constrain access. Pair this with egress controls (NACLs, security groups, and routing) to avoid “anything can reach the public internet” paths.

Multi-tenant design and isolation patterns

For shared tables, include a tenant identifier in the partition key (for example, TENANT#<id>), then enforce tenant isolation with IAM conditions on dynamodb:LeadingKeys.

If you need stronger isolation, consider separate tables per tenant or per environment, and reserve shared-table designs for cases where operational simplicity and cost efficiency outweigh stricter blast-radius requirements.

Cost Optimization for DynamoDB

DynamoDB is often “cheap when you’re precise, expensive when you’re vague.” Costs typically follow your access patterns, so the best optimization work starts by making those patterns explicit.

Know the cost drivers

Your bill is mainly shaped by:

  • Reads and writes (RCUs/WCUs in provisioned mode, request units in on-demand)
  • Storage (table data plus item size)
  • Secondary indexes (each GSI has its own write and storage costs)
  • Streams (read requests against stream records and any downstream consumers)

A common surprise: every write to a table is also a write to each affected GSI, so “just one more index” can multiply write cost.

Design keys to avoid waste

Good key design reduces the need for expensive operations. If you frequently reach for Scan, you’re paying to read data you’ll throw away.

Prefer:

  • Query by partition key (and optionally sort key conditions)
  • Narrow projections in your GSIs (project only attributes you truly need)

If an access pattern is rare, consider serving it via a separate table, an ETL job, or a cached read model rather than a permanent GSI.

Control storage with TTL and lifecycle

Use TTL to automatically delete short-lived items (sessions, temporary tokens, intermediate workflow state). This trims storage and can keep indexes smaller over time.

For append-heavy data (events, logs), combine TTL with sort-key designs that let you query “recent only,” so you don’t routinely touch cold history.

Right-size capacity and avoid accidental spikes

In provisioned mode, set conservative baselines and scale with auto scaling based on real metrics. In on-demand mode, watch for inefficient patterns (large items, chatty clients) that drive request volume.

Treat Scan as a last resort—when you truly need full-table processing, schedule it off-peak or run it as a controlled batch with pagination and backoff.

When to Choose DynamoDB (and When Not To)

DynamoDB shines when your application can be expressed as a set of well-defined access patterns and you need consistently low latency at high scale. If you can describe your reads and writes up front (by partition key, sort key, and a small number of indexes), it’s often one of the simplest ways to operate a highly available data store.

Great fits

DynamoDB is a strong choice when you have:

  • Predictable queries (fetch user profile, list a user’s orders by time, load a session by ID)
  • High write throughput or spiky traffic you don’t want to babysit
  • A need for horizontal scaling without managing servers
  • Event-driven designs using Streams to trigger downstream work

When to consider alternatives

Look elsewhere if your core requirements include:

  • Complex joins across many entities or frequent relationship traversals
  • Ad hoc querying and analytics that change weekly (group-bys, exploratory filters)
  • Heavy text search and relevance ranking without an external index

Hybrid approaches that work well

Many teams keep DynamoDB for “hot” operational reads and writes, then add:

  • S3 + Athena for analytics and historical reporting
  • OpenSearch (or similar) for full-text search and faceting
  • A cache layer when certain keys are extremely read-heavy

Prototyping note: shorten the path from model to app

If you’re validating access patterns and single-table conventions, speed matters. Teams sometimes prototype the surrounding service and UI in Koder.ai (a vibe-coding platform that builds web, server, and mobile apps from chat) and then iterate on the DynamoDB key design as real query paths emerge. Even if your production backend differs, quick end-to-end prototypes help reveal which queries should be Query operations versus which ones would accidentally become expensive scans.

Quick decision checklist

Validate: (1) your top queries are known and key-based, (2) correctness needs match the consistency model, (3) expected item sizes and growth are understood, and (4) the cost model (on-demand vs. provisioned plus autoscaling) fits your budget.

FAQ

What is DynamoDB, and when is it a good choice?

DynamoDB is a fully managed NoSQL database on AWS designed for consistently low-latency reads/writes at very high scale. Teams use it when they can define key-based access patterns (fetch by ID, list by owner, time-range queries) and want to avoid running database infrastructure.

It’s especially common in microservices, serverless apps, and event-driven systems.

What are tables, items, and attributes in DynamoDB?

A table holds items (like rows). Each item is a flexible set of attributes (like columns) and can include nested data.

DynamoDB works well when one request typically needs “the whole entity,” because items can contain maps and lists (JSON-like structures).

What’s the difference between a simple primary key and a composite primary key?

A partition key alone uniquely identifies an item (simple primary key). A partition key + sort key (composite key) lets multiple items share the same partition key while staying uniquely identifiable and ordered by the sort key.

Composite keys enable patterns like:

  • “All orders for a customer”
  • “Events for a device between timestamps”
When should I use Query vs Scan?

Use Query when you can specify the partition key (and optionally a sort key condition). It’s the fast, scalable path.

Use Scan only when you truly need to read everything; it reads the whole table or index and filters after the fact, which is usually slower and more expensive.

If you’re scanning frequently, it’s a sign your key or index design needs adjustment.

What are GSIs and LSIs, and how do I choose?

Secondary indexes provide alternate query paths.

  • GSI (Global Secondary Index): can use a different partition key (and optional sort key) from the base table; can be added later.
  • LSI (Local Secondary Index): shares the same partition key as the base table but uses a different sort key; must be defined when the table is created.

Indexes increase write cost because writes are replicated into the index.

How do I choose between On-Demand and Provisioned capacity?

Choose On-Demand if traffic is unpredictable, spiky, or you don’t want to manage capacity. You pay per request.

Choose Provisioned if usage is steady/predictable and you want more controlled costs. Pair it with auto scaling, but remember it may not react instantly to sudden spikes.

What consistency options does DynamoDB offer, and when do they matter?

By default, reads are eventually consistent, meaning you might briefly read older data right after a write.

Use strongly consistent reads (when available) for critical “must be current” checks, such as authorization gates or workflow state transitions.

For correctness under concurrency, prefer atomic updates (e.g., conditional writes or ADD) over read-modify-write loops.

When should I use DynamoDB transactions?

Transactions (TransactWriteItems, TransactGetItems) provide ACID guarantees across up to 25 items.

Use them when you must update multiple items together (e.g., create an order and reserve inventory) or enforce invariants that can’t tolerate partial updates.

They cost more and add latency, so reserve them for the flows that truly require them.

What are hot keys/partitions, and how can I avoid them?

Hot partitions happen when too many requests target the same partition key value (or a small set of values), causing throttling even if the table is otherwise underused.

Common mitigations:

  • Pick high-cardinality partition keys
  • Add write sharding (small random/hash suffix)
  • Use time buckets for time-series data
  • Implement exponential backoff with jitter on throttles
How do DynamoDB Streams support event-driven architectures?

Enable DynamoDB Streams to get a change feed for inserts, updates, and deletes. A common pattern is Streams → Lambda to trigger downstream work.

Important guarantees to design for:

  • Ordering is per shard (not global)
  • Delivery is at-least-once (duplicates can occur)

Make consumers idempotent (upsert by key, use conditional writes, or track processed event IDs).

Related posts