The DynamoDB Document API is actually a thin, convenient wrapper around the Low-Level API, but understanding when to use each unlocks significant performance and cost efficiencies.

Let’s see it in action. Imagine we have a users table with a userId (partition key) and email (sort key).

Here’s how you’d get a user by userId and email using the Document API:

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('users')

response = table.get_item(
    Key={
        'userId': 'user-123',
        'email': 'alice@example.com'
    }
)
item = response.get('Item')
print(item)

This looks simple, right? Under the hood, table.get_item translates this into a GetItem call to the DynamoDB service, specifying the TableName, Key (which is a map of attribute names to attribute values), and potentially ProjectionExpression if you’re only fetching specific attributes.

Now, here’s the same operation using the Low-Level API (often referred to as the "Client" API):

import boto3

client = boto3.client('dynamodb')

response = client.get_item(
    TableName='users',
    Key={
        'userId': {'S': 'user-123'},
        'email': {'S': 'alice@example.com'}
    }
)
item = response.get('Item')
print(item)

Notice the key difference: the Key parameter in the Low-Level API requires explicit DynamoDB data types ({'S': 'user-123'}). The Document API handles this type marshalling for you. This convenience comes at a slight overhead, primarily in the client-side processing.

The Document API is fantastic for typical CRUD operations where you’re dealing with single items or small batches, and you want to minimize code complexity. It abstracts away the need to think about DynamoDB’s specific data type descriptors (like {'S': 'string'}, {'N': '123'}, {'BOOL': True}, {'L': [...]} for lists, {'M': {...}} for maps). When you use table.put_item(Item={'name': 'Alice', 'age': 30}), the Document API automatically formats this into {'name': {'S': 'Alice'}, 'age': {'N': '30'}} for the underlying PutItem call.

The Low-Level API, on the other hand, gives you direct access to every DynamoDB operation and every parameter. This is where you gain fine-grained control, especially for complex queries, transactions, and when optimizing for performance or cost. For instance, when performing a Query or Scan operation with the Document API, it might perform multiple round trips or fetch more data than necessary if you’re not careful with FilterExpression and ProjectionExpression. The Low-Level API allows you to construct these expressions with maximum precision, directly specifying attribute names and their types within conditions.

Consider a scenario where you need to perform a conditional update.

Document API:

response = table.update_item(
    Key={
        'userId': 'user-123',
        'email': 'alice@example.com'
    },
    UpdateExpression='SET status = :s',
    ConditionExpression='attribute_exists(userId)',
    ExpressionAttributeValues={
        ':s': 'active'
    }
)

Low-Level API:

response = client.update_item(
    TableName='users',
    Key={
        'userId': {'S': 'user-123'},
        'email': {'S': 'alice@example.com'}
    },
    UpdateExpression='SET #st = :s',
    ConditionExpression='attribute_exists(userId)',
    ExpressionAttributeNames={
        '#st': 'status'
    },
    ExpressionAttributeValues={
        ':s': {'S': 'active'}
    }
)

The Low-Level API requires explicit mapping of attribute names in ExpressionAttributeNames to avoid reserved word conflicts and explicit type declarations in ExpressionAttributeValues. While more verbose, this direct control is crucial for complex operations.

When you’re performing bulk operations, especially writes, the Document API’s batch_writer is convenient, but it doesn’t offer the same level of control over batch sizes or error handling as constructing BatchWriteItem calls directly with the Low-Level API. The batch_writer abstracts the logic of splitting items into batches of 25 and retrying failed items, which is great for simplicity, but you lose the ability to fine-tune those parameters or implement custom retry logic.

The subtle but significant difference in how data is represented is the core of why you’d choose one over the other. The Document API uses native Python types (strings, ints, lists, dicts) and marshals them into DynamoDB’s JSON-like attribute value format. The Low-Level API requires you to provide this format explicitly. This means the Document API is easier to read and write for common cases, but the Low-Level API gives you direct control over the exact payload sent to DynamoDB, which is critical for performance tuning, understanding exact costs, and leveraging advanced features like condition expressions with specific data types.

For example, if you are dealing with large numbers or need to ensure precise numerical comparisons in your ConditionExpression, the Document API might infer a number type, but the Low-Level API allows you to specify it as a string representation of the number (e.g., {'N': '123.45'}) to avoid potential floating-point precision issues that can arise from client-side type conversions. This explicit control over data type representation is often overlooked but is key when working with numerical data in DynamoDB, especially for range-based queries or strict conditional updates where even a tiny difference in representation can cause an operation to fail unexpectedly.

The next step is understanding how to optimize your data modeling and query patterns, which often involves leveraging the Low-Level API’s capabilities for advanced indexing and projection.

Want structured learning?

Take the full Dynamodb course →