Back to Blog
Python

Python Boto3 DynamoDB Basics: CRUD Operations

python boto3 dynamodb basics: Learn how to use boto3 with DynamoDB: client setup, table access, CRUD operations, querying, error handling, and operational considerations.

boto3DynamoDBAWS SDKNoSQLPython
An illustration of a Python boto3 client sending data packets to a DynamoDB database table, representing CRUD operations and query workflows.

Setting Up the boto3 Client

The starting point for python boto3 DynamoDB basics is creating a client object. boto3 offers two interfaces for DynamoDB: the low-level client and the higher-level resource. Production code commonly uses the client because it exposes the complete API and returns responses that match the service's JSON protocol directly.

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

The client resolves credentials from environment variables, shared credential files, IAM roles, or the EC2 instance metadata service, in that order. For local development against DynamoDB Local or a containerized instance, pass an endpoint URL:

dynamodb = boto3.client( "dynamodb", endpoint_url="http://localhost:8000", region_name="us-east-1", )

Omit endpoint_url in production so boto3 uses the regional endpoint derived from the region name.

The resource interface is worth knowing about because it wraps responses in Python-native types:

dynamodb = boto3.resource("dynamodb") table = dynamodb.Table("orders")

With the resource, numeric attribute values come back as Decimal objects instead of strings, which matters when you serialize responses to JSON. The client interface, by contrast, returns numbers as strings in the type-wrapped format. Pick one interface and stay consistent across a codebase.

Creating and Describing a Table

Before writing items, the table must exist. create_table requires the key schema and attribute definitions. The example below creates a table with a composite primary key: order_id as the partition key and line_item as the sort key.

try: dynamodb.create_table( TableName="orders", KeySchema=[ {"AttributeName": "order_id", "KeyType": "HASH"}, {"AttributeName": "line_item", "KeyType": "RANGE"}, ], AttributeDefinitions=[ {"AttributeName": "order_id", "AttributeType": "S"}, {"AttributeName": "line_item", "AttributeType": "N"}, ], BillingMode="PAY_PER_REQUEST", ) except dynamodb.exceptions.ResourceInUseException: pass

AttributeType values are S for string, N for number, and B for binary. BillingMode="PAY_PER_REQUEST" means you pay per request rather than provisioning fixed read and write capacity. For a table that already exists, create_table raises ResourceInUseException, so the call is wrapped in a try/except when the code may run more than once.

To confirm the table is ready, call describe_table and check TableStatus:

response = dynamodb.describe_table(TableName="orders") status = response["Table"]["TableStatus"]

A newly created table is not immediately usable; it transitions through CREATING before reaching ACTIVE. Code that writes immediately after create_table should poll describe_table until the status is ACTIVE.

Writing Items with put_item

put_item writes a single item and replaces an existing item with the same primary key. Every attribute value must be wrapped in a type descriptor:

response = dynamodb.put_item( TableName="orders", Item={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, "customer": {"S": "acme-corp"}, "amount": {"N": "249.50"}, "status": {"S": "pending"}, }, )

The type-wrapped format is verbose but unambiguous: {"S": "..."} for strings, {"N": "..."} for numbers, {"B": b"..."} for binary, and {"BOOL": True} for booleans. Lists and maps use {"L": [...]} and {"M": {...}}.

To avoid overwriting an existing item, add a condition:

dynamodb.put_item( TableName="orders", Item={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, "status": {"S": "pending"}, }, ConditionExpression="attribute_not_exists(order_id)", )

When the condition fails, DynamoDB raises ConditionalCheckFailedException and the item is not written. This is the standard way to implement create-only semantics.

Reading Items with get_item

get_item retrieves an item by its full primary key. For a composite key, both the partition key and sort key are required:

response = dynamodb.get_item( TableName="orders", Key={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, }, ) item = response.get("Item")

If no item matches the key, the response contains no Item key, so response.get("Item") returns None.

get_item uses eventually consistent reads by default. To force a strongly consistent read, pass ConsistentRead=True:

response = dynamodb.get_item( TableName="orders", Key={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, }, ConsistentRead=True, )

Strongly consistent reads reflect the latest write but consume twice the read capacity of eventually consistent reads, and they are not supported on global secondary indexes. For most use cases, the default eventually consistent read is sufficient.

Querying with Query and Scan

query reads items that share the same partition key. The partition key value is required; the sort key condition is optional. This example returns all line items for one order:

response = dynamodb.query( TableName="orders", KeyConditionExpression="order_id = :oid", ExpressionAttributeValues={ ":oid": {"S": "ORD-1001"}, }, ) items = response["Items"]

The KeyConditionExpression supports comparison operators on the sort key, such as begins_with, BETWEEN, and >. A query can only target one partition key value per call.

scan reads every item in the table without a key condition:

response = dynamodb.scan(TableName="orders")

A scan reads the entire table, so it consumes read capacity proportional to the total table size. On large tables, a scan is slow and expensive. Use scan for one-off analysis or small tables, and prefer query whenever the access pattern is known.

Both query and scan return up to 1 MB of data per call. When more data remains, the response includes a LastEvaluatedKey. Pass it as ExclusiveStartKey in the next call to continue pagination:

response = dynamodb.query( TableName="orders", KeyConditionExpression="order_id = :oid", ExpressionAttributeValues={":oid": {"S": "ORD-1001"}}, ExclusiveStartKey=response.get("LastEvaluatedKey"), )

Updating and Deleting Items

update_item modifies specific attributes of an existing item. The UpdateExpression uses SET, REMOVE, ADD, or DELETE actions:

response = dynamodb.update_item( TableName="orders", Key={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, }, UpdateExpression="SET #st = :new_status", ExpressionAttributeNames={"#st": "status"}, ExpressionAttributeValues={":new_status": {"S": "shipped"}}, ReturnValues="ALL_NEW", )

The #st placeholder in ExpressionAttributeNames is required when an attribute name is a DynamoDB reserved word or contains characters that are not allowed in an expression. ReturnValues="ALL_NEW" returns the item as it appears after the update; "UPDATED_NEW" returns only the changed attributes.

delete_item removes an item by primary key:

dynamodb.delete_item( TableName="orders", Key={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, }, )

Like put_item, both operations accept a ConditionExpression. For example, a delete that only succeeds when the status is cancelled:

dynamodb.delete_item( TableName="orders", Key={ "order_id": {"S": "ORD-1001"}, "line_item": {"N": "1"}, }, ConditionExpression="#st = :cancelled", ExpressionAttributeNames={"#st": "status"}, ExpressionAttributeValues={":cancelled": {"S": "cancelled"}}, )

If the condition is not met, ConditionalCheckFailedException is raised and no data is changed.

Error Handling and Retries

DynamoDB failures fall into two groups: service-side errors and client-side errors. The most common service-side errors are:

  • ResourceNotFoundException — the table or item does not exist
  • ConditionalCheckFailedException — a condition expression evaluated to false
  • ProvisionedThroughputExceededException — the request exceeded provisioned capacity
  • ThrottlingException — the request was throttled

boto3 retries throttling and transient errors automatically. The default retry mode is legacy; the standard mode retries a broader set of errors with exponential backoff. Configure it explicitly for production workloads:

from botocore.config import Config config = Config(retries={"max_attempts": 5, "mode": "standard"}) dynamodb = boto3.client("dynamodb", config=config)

max_attempts includes the initial request, so a value of 5 means the original call plus four retries. On-demand tables can still throttle under sudden traffic spikes; the retry configuration handles that case.

Operational Considerations

Two operational concerns matter even in basic DynamoDB usage: capacity mode and item size.

Capacity mode. PAY_PER_REQUEST (on-demand) is the simplest choice for development and for workloads with unpredictable traffic. Provisioned mode requires specifying read and write capacity units and monitoring utilization; it is cheaper for steady, predictable traffic. The choice affects how you handle ProvisionedThroughputExceededException — under provisioned mode, throttling is more likely under load.

Item size. A single item, including all attribute names and values, cannot exceed 400 KB. This limit applies to the binary-encoded representation, not the JSON size. If your items approach this limit, reconsider the data model; storing large blobs in DynamoDB is rarely the right choice.

Pagination. As noted earlier, query and scan return at most 1 MB per call. For production code, use the paginator interface rather than manually tracking LastEvaluatedKey:

paginator = dynamodb.get_paginator("query") for page in paginator.paginate( TableName="orders", KeyConditionExpression="order_id = :oid", ExpressionAttributeValues={":oid": {"S": "ORD-1001"}}, ): for item in page["Items"]: print(item)

The paginator handles LastEvaluatedKey and ExclusiveStartKey automatically, which removes a common source of off-by-one errors in manual pagination loops.

python boto3 dynamodb basics: Practical Usage and Code Examp | RYUSLOG DEV