DS DevShelfHub Projects · AI tools
Cheatsheets / DynamoDB
Cheatsheet · Dev tooling

DynamoDB: Items, Expressions, GSI and Transactions Reference Guide

By DevShelfHub

Item API, expressions, GSI vs LSI, PartiQL, transactions, streams, Lambda triggers, DAX, TTL, and backups — the complete DynamoDB reference for boto3 1.34+ and AWS CLI v2. Covers the Resource and Client APIs, key design patterns, and local development with dynamodb-local.

94 items 9 min Key-value GSI PartiQL

Start hereQuick start · 6 you’ll reach for daily

Create tableaws dynamodb create-table ...
Put itemtable.put_item(Item={...})
Get itemtable.get_item(Key={...})
Query by PKKey('pk').eq(...) & Key('sk').begins_with(...)
UpdateUpdateExpression='SET #s = :s'
Local devdynamodb-local (docker)

Target versions · paceVersions

Targets: boto3 ≥ 1.34 AWS CLI v2 dynamodb-local latest (for dev)

DynamoDB is fully managed — the API is the version. Recent flagship additions: PartiQL (SQL-style queries), transactional writes, on-demand backups + PITR, DynamoDB Streams → Kinesis, and the boto3 Resource API (the type-friendly wrapper that handles Decimal coercion for you). Always prefer the Resource API in Python unless you need a feature the Client exposes first.

CLI · local · boto3Setup

bash
# AWS CLI (already-installed env)
aws --version
aws configure                # access key / secret / region

# Local-only dev — no AWS account needed
docker run -d --name ddb \
    -p 8000:8000 \
    amazon/dynamodb-local:latest \
    -jar DynamoDBLocal.jar -sharedDb

# Point the CLI at the local endpoint
aws dynamodb list-tables --endpoint-url http://localhost:8000

# Python SDK
pip install "boto3>=1.34"

python - <<'PY'
import boto3
ddb = boto3.resource("dynamodb", endpoint_url="http://localhost:8000",
                    region_name="us-east-1")
print([t.name for t in ddb.tables.all()])
PY

# Browse / model visually with NoSQL Workbench (separate desktop tool)
# https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.html

Keys · capacity modesTables

--key-schema AttributeName=pk,KeyType=HASHPartition key only — simple key-value table.
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGEComposite primary key (partition + sort).
--billing-mode PAY_PER_REQUESTOn-demand Strong default. No capacity sizing.
--billing-mode PROVISIONED --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5Cheaper for stable / predictable load.
aws dynamodb describe-table --table-name ordersSchema, indexes, status, stream config.
aws dynamodb update-table --billing-mode PROVISIONEDSwap modes — once per 24h per table.
aws dynamodb wait table-exists --table-name ordersBlock until ACTIVE.
aws dynamodb delete-table --table-name ordersHard delete — no soft state, no recycle bin (unless PITR is on).
Item max size400 KB total (all attributes + names). Stream & store blobs in S3 above this.
Partition hot-spottingAvoid pk patterns where one value dominates traffic — one node serves one partition.
bash
# Orders table — composite key (customer_id + order_id) and a GSI on status.
# Single-table design: PK is the entity identifier, SK is the entity sort.
aws dynamodb create-table \
    --table-name orders \
    --attribute-definitions \
        AttributeName=pk,AttributeType=S \
        AttributeName=sk,AttributeType=S \
        AttributeName=status,AttributeType=S \
        AttributeName=created,AttributeType=S \
    --key-schema \
        AttributeName=pk,KeyType=HASH \
        AttributeName=sk,KeyType=RANGE \
    --billing-mode PAY_PER_REQUEST \
    --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES \
    --global-secondary-indexes '[
        {
          "IndexName":   "gsi_status",
          "KeySchema":   [
            {"AttributeName":"status","KeyType":"HASH"},
            {"AttributeName":"created","KeyType":"RANGE"}
          ],
          "Projection":  {"ProjectionType":"ALL"}
        }
    ]'

# Wait until ACTIVE (and the GSI is ACTIVE too)
aws dynamodb wait table-exists --table-name orders

# Enable TTL on a numeric "expires" attribute (epoch seconds)
aws dynamodb update-time-to-live --table-name orders \
    --time-to-live-specification 'Enabled=true,AttributeName=expires'

Wire types · setsData types

SString, UTF-8, no length cap (item is still 400 KB).
NNumber, transmitted as string. Up to 38 digits precision. Use Decimal in Python.
BBinary (raw bytes).
BOOL · NULLBoolean and explicit null.
MMap (object). Nested arbitrarily.
LList (array). Heterogeneous elements OK.
SS · NS · BSString / Number / Binary sets. Distinct, unordered. Can’t be empty.
No native Date typeStore ISO-8601 strings (sortable) or epoch seconds as N (cheap to compare).
boto3 Resource → DecimalNumbers come back as Decimal; float is rejected on write to avoid precision loss.

Item APICRUD

table.put_item(Item={...})Insert / overwrite. Idempotent on the key.
table.put_item(..., ConditionExpression='attribute_not_exists(pk)')Create-only — rejects if the key already exists.
table.get_item(Key={'pk':'..', 'sk':'..'})Default: eventually-consistent read.
table.get_item(..., ConsistentRead=True)Strongly consistent — 2× the read cost.
table.update_item(Key=..., UpdateExpression='SET #s = :s')Mutate. Always use UpdateExpression, never replace-then-put.
..., ConditionExpression='#s = :prev'Compare-and-set semantics on the same call.
..., ReturnValues='ALL_NEW'Avoid a follow-up GetItem.
table.delete_item(Key=..., ReturnValues='ALL_OLD')Delete + read prior state.
with table.batch_writer() as b: b.put_item(...)Preferred Auto-batches 25 at a time + retries unprocessed.
client.batch_get_item(RequestItems={'orders':{'Keys':[...]}})Up to 100 keys per call across tables.
from boto3.dynamodb.conditions import Key, AttrExpression builders — cleaner than raw expression strings.
dynamodb.meta.client.put_item(TableName=..., Item={'pk':{'S':'..'}})Low-level client — you supply the typed wire form.
python
import boto3
from boto3.dynamodb.conditions import Key, Attr
from decimal import Decimal

ddb   = boto3.resource("dynamodb", region_name="us-east-1")
table = ddb.Table("orders")

# Create (only if order_id is new) — atomic check-and-insert
table.put_item(
    Item={
        "pk":       "CUSTOMER#42",
        "sk":       "ORDER#2026-05-19-abc",
        "status":   "pending",
        "created":  "2026-05-19T10:00:00Z",
        "total":    Decimal("19.95"),    # never float — precision matters for money
        "tags":     {"web", "first-order"},
    },
    ConditionExpression="attribute_not_exists(sk)"
)

# Read — strongly consistent
order = table.get_item(
    Key={"pk": "CUSTOMER#42", "sk": "ORDER#2026-05-19-abc"},
    ConsistentRead=True,
)["Item"]

# Update — atomic counter + status change, return the new state
table.update_item(
    Key={"pk": "CUSTOMER#42", "sk": "ORDER#2026-05-19-abc"},
    UpdateExpression="SET #s = :s, attempts = if_not_exists(attempts, :z) + :one",
    ConditionExpression="#s = :pending",
    ExpressionAttributeNames={"#s": "status"},
    ExpressionAttributeValues={":s":"paid", ":pending":"pending",
                               ":z": Decimal(0), ":one": Decimal(1)},
    ReturnValues="ALL_NEW",
)

# Batch writes — auto-retries unprocessed items
with table.batch_writer() as batch:
    for row in rows:
        batch.put_item(Item=row)

Key conditions · sort key rangesQueries

Key('pk').eq('u#1')Partition key — equality only.
& Key('sk').begins_with('ORDER#')Sort-key range. Pair with eq on pk.
& Key('sk').between('2026-01-01','2026-12-31')Inclusive range on sort key.
ScanIndexForward=FalseDescending by sort key.
Limit=20Page size. Not a row-count cap on the filter.
FilterExpression=Attr('status').eq('paid')Filters after read — you still pay for everything matched by the key condition.
ProjectionExpression='id, #s'Pick attributes. Reduces RCU / network.
paginator = client.get_paginator('query')Hides LastEvaluatedKey for you.
ExclusiveStartKey=resp['LastEvaluatedKey']Manual pagination cursor — opaque, pass it back as-is.
IndexName='gsi_status'Query a secondary index instead of the base table.
python
import boto3
from boto3.dynamodb.conditions import Key, Attr

ddb   = boto3.resource("dynamodb", region_name="us-east-1")
table = ddb.Table("orders")

# 1. All orders for one customer, newest first
resp = table.query(
    KeyConditionExpression = Key("pk").eq("CUSTOMER#42") & Key("sk").begins_with("ORDER#"),
    ScanIndexForward       = False,            # DESC by sort key
    Limit                  = 25,
    ProjectionExpression   = "sk, #s, total",
    ExpressionAttributeNames = {"#s": "status"},
)
items, next_key = resp["Items"], resp.get("LastEvaluatedKey")

# 2. Paginate the rest — paginator handles LastEvaluatedKey for you
paginator = table.meta.client.get_paginator("query")
for page in paginator.paginate(
    TableName              = "orders",
    KeyConditionExpression = "pk = :p AND begins_with(sk, :prefix)",
    ExpressionAttributeValues = {":p": {"S":"CUSTOMER#42"}, ":prefix": {"S":"ORDER#"}},
    PaginationConfig       = {"PageSize": 100},
):
    for item in page["Items"]:
        handle(item)

# 3. Query a GSI — all "paid" orders across customers in the last 7 days
resp = table.query(
    IndexName              = "gsi_status",
    KeyConditionExpression = Key("status").eq("paid") &
                             Key("created").gte("2026-05-12T00:00:00Z"),
    FilterExpression       = Attr("total").gte(50),
)

Last resortScan

table.scan()Avoid in prod Full-table read. Costs RCU for every item, not every match.
scan(FilterExpression=Attr('field').eq('v'))Filter applied after the scan — doesn’t reduce cost.
scan(Segment=0, TotalSegments=8)Parallel scan. One worker per segment.
ConsistentRead=TrueStrongly-consistent scan. Even more expensive.
Better: add a GSIIf you find yourself scanning, you usually want a sparse GSI on the filter column.
aws dynamodb scan --table-name t --max-items 5Handy for ad-hoc inspection from the CLI.

SET · ADD · REMOVE · DELETEExpressions

SET #a = :a, updated = :nowStandard assignment.
SET tags = list_append(if_not_exists(tags, :empty), :more)Append safely even if the attribute is missing.
ADD #counter :nAtomic counter (numbers) or set union (SS/NS/BS).
REMOVE #attr, #otherDrop attributes from the item.
DELETE #tags :remRemove members from an SS / NS / BS.
ConditionExpression='attribute_exists(pk)'Item must exist.
'attribute_not_exists(pk)'Insert-only.
'size(#tags) < :max'Cap the size of a list / map / set.
ExpressionAttributeNames = {'#s':'status'}Use #alias for reserved words or attribute names with dots.
ExpressionAttributeValues = {':s':'paid', ':n': Decimal(1)}:placeholder form — typed at runtime.

GSI · LSISecondary indexes

GSI (Global Secondary Index)Different PK / SK. Eventually consistent. Add or drop anytime. Own RCU/WCU.
LSI (Local Secondary Index)Same PK, alternate SK. Strongly-consistent option. Must be defined at table creation.
--global-secondary-indexes IndexName=...,KeySchema=...,Projection={ProjectionType=ALL}Add a GSI on creation or via update-table.
ProjectionType: ALL | KEYS_ONLY | INCLUDETrade index size for which attributes show up in query results.
Sparse GSIAttribute set on only some items → the GSI shrinks to those items. Strong default for status / state filters.
Overloaded attributes (single-table design)Reuse pk / sk for multiple entity types — CUSTOMER#42, ORDER#abc, PROD#sku-1.
Limits: 20 GSI · 5 LSI per tablePlan before you add.
Backfill is asyncNew GSI takes time. Check IndexStatus before relying on it.

SQL-style queriesPartiQL

SELECT * FROM orders WHERE pk = ? AND begins_with(sk, ?)Reads — same access-pattern rules as Query.
INSERT INTO orders VALUE { 'pk':'u#1', 'sk':'ORDER#1', 'status':'paid' }Literal map syntax.
UPDATE orders SET status = ? WHERE pk = ? AND sk = ?Single-item mutation.
DELETE FROM orders WHERE pk = ? AND sk = ?Hard delete.
client.execute_statement(Statement=..., Parameters=[...])boto3 entry point. Parameters use the typed wire form.
client.batch_execute_statement(Statements=[...])Up to 25 per call.

All-or-nothingTransactions

client.transact_write_items(TransactItems=[ {Put:{...}}, {Update:{...}}, {Delete:{...}}, {ConditionCheck:{...}} ])Up to 100 actions. Either all apply or none.
ClientRequestToken='uuid-...'Idempotency key — safe to retry inside a 10-min window.
client.transact_get_items(TransactItems=[{Get:{...}}])Strongly-consistent multi-get across tables.
TransactionCanceledExceptionOne predicate failed — inspect CancellationReasons per item.
Cost2× normal WCU/RCU per item. Use for correctness, not for bulk load.
python
import boto3, uuid
from botocore.exceptions import ClientError

client = boto3.client("dynamodb", region_name="us-east-1")

# Atomic order placement:
#   1. Create the order row (only if it doesn't already exist)
#   2. Decrement stock on the product (only if stock >= qty)
# Either both succeed or neither does.
try:
    client.transact_write_items(
        TransactItems = [
            {"Put": {
                "TableName": "orders",
                "Item": {
                    "pk":     {"S": "CUSTOMER#42"},
                    "sk":     {"S": "ORDER#2026-05-19-abc"},
                    "status": {"S": "pending"},
                    "total":  {"N": "19.95"},
                },
                "ConditionExpression": "attribute_not_exists(sk)"
            }},
            {"Update": {
                "TableName": "inventory",
                "Key":        {"sku": {"S":"SKU-1"}},
                "UpdateExpression":   "SET stock = stock - :q",
                "ConditionExpression": "stock >= :q",
                "ExpressionAttributeValues": {":q": {"N":"1"}}
            }},
        ],
        ClientRequestToken = str(uuid.uuid4()),   # 10-min idempotency window
    )
except ClientError as e:
    if e.response["Error"]["Code"] == "TransactionCanceledException":
        # Inspect CancellationReasons[i].Code (None / ConditionalCheckFailed / ...)
        reasons = e.response["CancellationReasons"]
        raise ValueError(f"transaction rejected: {reasons}") from e
    raise

Change data captureStreams & Lambda triggers

StreamSpecification: { StreamEnabled: true, StreamViewType: 'NEW_AND_OLD_IMAGES' }Enable on the table.
StreamViewType: KEYS_ONLY | NEW_IMAGE | OLD_IMAGE | NEW_AND_OLD_IMAGESHow much of each item the stream carries.
aws lambda create-event-source-mapping --event-source-arn ...stream:... --function-name fn --batch-size 100Wire a Lambda trigger to a stream.
--filter-criteria 'Filters=[{Pattern="{\"eventName\":[\"INSERT\"]}"}]'Process only INSERTs without invoking the function on others.
BisectBatchOnFunctionError = trueHalve batches on error — isolates the poison record.
Kinesis Data Streams (alternative)Higher throughput, fan-out, 365-day retention. Heavier to wire.

Cache · retention · PITRDAX, TTL & backups

DAXManaged in-memory write-through cache. Microsecond reads for cached items. Drop-in client API.
aws dynamodb update-time-to-live --table-name t --time-to-live-specification 'Enabled=true,AttributeName=ttl'Auto-delete items — epoch seconds attribute. Deletion is best-effort within ~48h of expiry.
PITR (Point-in-time recovery)Continuous backups, last 35 days. Restore to any second.
aws dynamodb update-continuous-backups --table-name t --point-in-time-recovery-specification 'PointInTimeRecoveryEnabled=true'Enable PITR. Cheap insurance.
aws dynamodb create-backup --table-name t --backup-name nightlyOn-demand snapshot to S3.
aws dynamodb export-table-to-point-in-time --table-arn ... --s3-bucket ... --export-format DYNAMODB_JSONBulk export — feed analytics without touching live RCU.

Full pipeline · ~30 linesEnd-to-end · Order tracking

Single table with CUSTOMER#<id> partition key and ORDER#<ts> sort key. Three operations: place an order (insert-only), mark it paid (conditional update), list all orders for a customer (Query on pk).

python
# End-to-end: order tracking app in ~30 lines.
# Single table "orders" with PK=CUSTOMER#, SK=ORDER#, GSI on status.
import boto3, uuid, datetime as dt
from boto3.dynamodb.conditions import Key
from decimal import Decimal

ddb   = boto3.resource("dynamodb", endpoint_url="http://localhost:8000",
                      region_name="us-east-1")
table = ddb.Table("orders")

def place_order(customer_id: str, total: Decimal) -> str:
    order_id = f"{dt.datetime.utcnow().isoformat(timespec='seconds')}Z-{uuid.uuid4().hex[:6]}"
    table.put_item(
        Item = {
            "pk":      f"CUSTOMER#{customer_id}",
            "sk":      f"ORDER#{order_id}",
            "status":  "pending",
            "created": order_id.split("-")[0],
            "total":   total,
        },
        ConditionExpression = "attribute_not_exists(sk)",
    )
    return order_id

def mark_paid(customer_id: str, order_id: str) -> dict:
    return table.update_item(
        Key = {"pk": f"CUSTOMER#{customer_id}", "sk": f"ORDER#{order_id}"},
        UpdateExpression   = "SET #s = :p",
        ConditionExpression = "#s = :pending",
        ExpressionAttributeNames  = {"#s": "status"},
        ExpressionAttributeValues = {":p":"paid", ":pending":"pending"},
        ReturnValues = "ALL_NEW",
    )["Attributes"]

def list_orders(customer_id: str) -> list:
    return table.query(
        KeyConditionExpression = Key("pk").eq(f"CUSTOMER#{customer_id}") &
                                 Key("sk").begins_with("ORDER#"),
        ScanIndexForward = False, Limit = 20,
    )["Items"]

Best practiceGood to know

Single-table design is the idiomatic shape. Overload pk / sk with prefixes (CUSTOMER#, ORDER#, PROD#) so one Query reaches everything related to an entity. Stop the moment you reach for a join.
Sparse GSI for status filters. Only set the GSI key attribute on items you actually want to find — the index then shrinks to just those rows. Cheap, fast, and grows with the relevant subset, not the whole table.
Use ConditionExpression instead of read-then-write. Atomic check + write in one round-trip means no race window. attribute_not_exists(pk) is the right way to do “create only if new”.

Common trapsWatch out for

FilterExpression doesn’t save money. You still pay RCU for every row matched by the key condition; the filter just hides them after the read. If you’re filtering big result sets, you need a GSI.
Don’t store float from Python. Boto3’s Resource API refuses float on write to prevent precision loss. Always pass Decimal for N attributes.
Hot partitions kill throughput. One partition = one physical node serving up to a few thousand WCU. If your pk pattern concentrates traffic (e.g. pk = "today"), you’ll throttle even though the table has plenty of overall capacity. Spread with a suffix or a hash bucket.

Go deeperSee also

DynamoDB FAQ

What is DynamoDB and when should I use it?

DynamoDB is a fully managed serverless key-value and document database from AWS. It scales to any traffic level with single-digit millisecond latency. Use it for user sessions, event logs, shopping carts, and any workload with a clear primary key access pattern and variable traffic.

How do I choose a good partition key in DynamoDB?

Choose a partition key with high cardinality that distributes traffic evenly across partitions. Avoid keys like status or date that concentrate writes on a single partition (hot-spotting). Good candidates are userId, orderId, or a composite like tenantId#entityId.

What is the difference between a GSI and an LSI in DynamoDB?

A Local Secondary Index (LSI) shares the same partition key as the base table but has a different sort key — it must be created at table creation time. A Global Secondary Index (GSI) can have any partition and sort key and can be added or removed at any time. GSIs are more flexible; LSIs are tighter and cheaper to query.

How do DynamoDB transactions work?

DynamoDB transactions group up to 100 item-level operations across one or more tables into an atomic all-or-nothing unit. Use transact_write_items for writes (Put, Update, Delete, ConditionCheck) and transact_get_items for reads. Transactions cost 2x the normal read/write capacity.

How do I query DynamoDB without scanning the whole table?

Always query using the primary key or a GSI key with table.query(KeyConditionExpression=Key("pk").eq(...)). Scans read every item and are expensive. If you find yourself scanning frequently, redesign the data model to support your access patterns with a GSI.

Does DynamoDB support SQL-style queries?

Yes, via PartiQL — a SQL-compatible query language that lets you use SELECT, INSERT, UPDATE, and DELETE syntax against DynamoDB. It is available in the console, CLI (execute-statement), and boto3 (client.execute_statement). PartiQL is convenient but still translates to the same key-based operations underneath.