Defining DynamoDB Models with boto3-assist: Best Practices and Patterns
Master the art of creating clean, maintainable DynamoDB models with boto3-assist. Learn about composite keys, GSIs, lambda functions, and advanced patterns for real-world applications.
After covering single-table design fundamentals and one-to-many relationships, let's dive deep into creating robust DynamoDB models with boto3-assist.
Models are the foundation of your application's data layer. They define structure, manage keys, handle serialization, and provide type safety. In this post, I'll show you how to create production-ready models that are both maintainable and powerful.
Models Are DTOs (Data Transfer Objects)
First, an important principle: Models in boto3-assist are pure data structures. They do NOT interact with the database directly. This separation of concerns keeps your code clean and testable.
# ❌ DON'T: Add database logic to models
class Product(DynamoDBModelBase):
def save(self):
db.save(self) # NO!
# ✅ DO: Keep models as pure data structures
class Product(DynamoDBModelBase):
# Just attributes and index definitions
pass
Database operations belong in service layers (which we'll cover in the next post).
Basic Model Structure
Every model follows this pattern:
from typing import Optional
from boto3_assist.dynamodb.dynamodb_model_base import DynamoDBModelBase
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
class Product(DynamoDBModelBase):
def __init__(
self,
id: Optional[str] = None,
name: Optional[str] = None,
price: float = 0.0,
description: Optional[str] = None
):
# 1. Always call super().__init__() first
super().__init__()
# 2. Define your attributes with type hints
self.id = id
self.name = name
self.price = price
self.description = description
# 3. Setup indexes last
self.__setup_indexes()
def __setup_indexes(self):
# Index definitions go here
pass
Key Points:
- Inherit from
DynamoDBModelBase - Call
super().__init__()first - Define attributes with type hints (use
Optionalfor nullable fields) - Call
_setup_indexes()at the end of__init__
The Critical Lambda Pattern
This is one of the most important concepts in boto3-assist: always use lambda functions for key values.
❌ Wrong - Value Set at Instantiation
primary.partition_key.value = DynamoDBKey.build_key(("product", self.id))
If self.id is None when the object is created, the key will be "product#None" forever, even if you change self.id later.
✅ Correct - Value Evaluated at Runtime
primary.partition_key.value = lambda: DynamoDBKey.build_key(("product", self.id))
The lambda ensures the key is generated using the current value of self.id when you serialize the model.
Why This Matters
# Create empty product
product = Product()
keys = product.indexes.primary.to_dict()
print(keys)
# Output: {'pk': 'product#None', 'sk': 'product#None'}
# Now set the ID
product.id = "abc-123"
keys = product.indexes.primary.to_dict()
print(keys)
# Output: {'pk': 'product#abc-123', 'sk': 'product#abc-123'}
# ✓ Key updated automatically because we used lambda!
Building Composite Keys
Use DynamoDBKey.build_key() to create composite keys with multiple parts:
# Single part
DynamoDBKey.build_key(("user", "alice"))
# Result: "user#alice"
# Multiple parts
DynamoDBKey.build_key(("tenant", "acme-corp"), ("user", "alice"))
# Result: "tenant#acme-corp#user#alice"
# With empty values (omits trailing #)
DynamoDBKey.build_key(("products", ""))
# Result: "products"
This is perfect for multi-tenant applications or hierarchical data.
Global Secondary Indexes (GSIs)
GSIs allow you to query data using different keys than your primary key. This is essential for flexible access patterns.
Basic GSI - List All Products
def __setup_indexes(self):
# ... primary key setup ...
# GSI to query all products sorted by name
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi0",
partition_key=DynamoDBKey(
attribute_name="gsi0_pk",
value=lambda: DynamoDBKey.build_key(("products", "")) # Static partition key
),
sort_key=DynamoDBKey(
attribute_name="gsi0_sk",
value=lambda: DynamoDBKey.build_key(("name", self.name))
)
)
)
Use case: Query all products: gsi0_pk = "products" on index gsi0
GSI with Category Filter
# GSI to query products by category
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi1",
partition_key=DynamoDBKey(
attribute_name="gsi1_pk",
value=lambda: DynamoDBKey.build_key(("category", self.category))
),
sort_key=DynamoDBKey(
attribute_name="gsi1_sk",
value=lambda: DynamoDBKey.build_key(("product", self.id))
)
)
)
Use case: Query all products in "electronics" category: gsi1_pk = "category#electronics"
Composite Sort Keys for Multi-Level Sorting
Want to sort by last name, then first name?
class User(DynamoDBModelBase):
def __setup_indexes(self):
# ... primary key ...
# GSI to query all users sorted by last name, then first name
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi1",
partition_key=DynamoDBKey(
attribute_name="gsi1_pk",
value=lambda: DynamoDBKey.build_key(("users", ""))
),
sort_key=DynamoDBKey(
attribute_name="gsi1_sk",
value=lambda: DynamoDBKey.build_key(
("lastname", self.last_name or ""),
("firstname", self.first_name or "")
)
)
)
)
Result: Users sorted alphabetically by last name, then first name.
Advanced Pattern: Dynamic Sort Keys
Sometimes you need conditional logic in your keys. Use a method reference:
class User(DynamoDBModelBase):
def __setup_indexes(self):
# ... primary key ...
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi2",
partition_key=DynamoDBKey(
attribute_name="gsi2_pk",
value=lambda: DynamoDBKey.build_key(("users", ""))
),
sort_key=DynamoDBKey(
attribute_name="gsi2_sk",
value=self._get_gsi2_sk # Method reference
)
)
)
def _get_gsi2_sk(self) -> str:
"""Custom logic for sort key"""
if self.last_name:
return f"lastname#{self.last_name}#firstname#{self.first_name or ''}"
return f"firstname#{self.first_name or ''}"
This allows complex conditional key generation.
Working with Timestamps
For sort keys with timestamps or numeric values, omit the prefix for pure numeric sorting:
class Order(DynamoDBModelBase):
def get_completed_utc_ts(self) -> float:
"""Get Unix timestamp of completion"""
if self.completed_utc is None:
return 0.0
return self.completed_utc.timestamp()
def __setup_indexes(self):
# GSI to query orders by completion date
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi0",
partition_key=DynamoDBKey(
attribute_name="gsi0_pk",
value=lambda: DynamoDBKey.build_key(("orders", ""))
),
sort_key=DynamoDBKey(
attribute_name="gsi0_sk",
# Empty string for prefix = pure numeric sort
value=lambda: DynamoDBKey.build_key(
("", self.get_completed_utc_ts())
)
)
)
)
Result: gsi0_sk = "1678901234.567" (pure timestamp, no prefix)
Complete Real-World Example
Here's a production-ready User model with multiple access patterns:
from typing import Optional
from boto3_assist.dynamodb.dynamodb_model_base import DynamoDBModelBase
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
class User(DynamoDBModelBase):
def __init__(
self,
id: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None
):
super().__init__()
self.id = id
self.first_name = first_name
self.last_name = last_name
self.email = email
self.status = "active"
self.__setup_indexes()
def __setup_indexes(self):
# PRIMARY: Get user by ID
primary = DynamoDBIndex()
primary.partition_key.attribute_name = "pk"
primary.partition_key.value = lambda: DynamoDBKey.build_key(
("user", self.id)
)
primary.sort_key.attribute_name = "sk"
primary.sort_key.value = lambda: DynamoDBKey.build_key(
("user", self.id)
)
self.indexes.add_primary(primary)
# GSI0: List all users
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi0",
partition_key=DynamoDBKey(
attribute_name="gsi0_pk",
value=lambda: DynamoDBKey.build_key(("users", ""))
),
sort_key=DynamoDBKey(
attribute_name="gsi0_sk",
value=lambda: DynamoDBKey.build_key(("user", self.id))
)
)
)
# GSI1: Search by name (last name, then first name)
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi1",
partition_key=DynamoDBKey(
attribute_name="gsi1_pk",
value=lambda: DynamoDBKey.build_key(("users", ""))
),
sort_key=DynamoDBKey(
attribute_name="gsi1_sk",
value=lambda: DynamoDBKey.build_key(
("lastname", self.last_name or ""),
("firstname", self.first_name or "")
)
)
)
)
# GSI2: Find users by status and email
self.indexes.add_secondary(
DynamoDBIndex(
index_name="gsi2",
partition_key=DynamoDBKey(
attribute_name="gsi2_pk",
value=lambda: DynamoDBKey.build_key(("status", self.status))
),
sort_key=DynamoDBKey(
attribute_name="gsi2_sk",
value=lambda: DynamoDBKey.build_key(("email", self.email))
)
)
)
Access Patterns Enabled:
- Get user by ID → Primary key
- List all users → GSI0
- Find users by last name → GSI1 with
begins_with - Find active users → GSI2 with
gsi2_pk = "status#active"
Serialization Methods
Models provide several methods to convert to dictionaries:
to_dictionary()
Returns a simple dictionary (excludes key attributes):
product = Product(id="123", name="Widget", price=29.99)
simple_dict = product.to_dictionary()
# {'id': '123', 'name': 'Widget', 'price': 29.99}
to_resource_dictionary()
Returns a dictionary for DynamoDB Resource API (includes all keys):
resource_dict = product.to_resource_dictionary()
# {
# 'pk': 'product#123',
# 'sk': 'product#123',
# 'gsi0_pk': 'products',
# 'gsi0_sk': 'name#Widget',
# 'id': '123',
# 'name': 'Widget',
# 'price': 29.99
# }
Use this when saving with db.save().
Deserialization: The map() Method
The map() method populates a model from a dictionary:
# From DynamoDB response
dynamodb_item = {
'id': 'abc-123',
'name': 'Widget',
'price': 29.99
}
product = Product().map(dynamodb_item)
print(product.name) # "Widget"
print(product.price) # 29.99 (automatically converted from Decimal!)
The map() method intelligently handles:
- Full DynamoDB responses
- Item-only responses
- Plain dictionaries
- Automatic Decimal conversion (no more TypeError!)
Debugging Keys
Use to_dict() on indexes to see generated keys:
product = Product(id="abc-123", name="Widget")
# Check primary key
print(product.indexes.primary.to_dict())
# {'pk': 'product#abc-123', 'sk': 'product#abc-123'}
# Check GSI key
print(product.indexes.get("gsi0").to_dict())
# {'gsi0_pk': 'products', 'gsi0_sk': 'name#Widget'}
# Partition key only
print(product.indexes.get("gsi0").to_dict(include_sort_key=False))
# {'gsi0_pk': 'products'}
Best Practices Checklist
- ✅ Models Are DTOs Only - No database logic in models
- ✅ Always Use Lambda for Keys - Ensures runtime evaluation
- ✅ Consistent Naming -
pk/skfor primary,gsi0_pk/gsi0_skfor GSIs - ✅ Type Hints - Use
Optional[str]for nullable fields - ✅ Design for Access Patterns - Know how you'll query before creating models
- ✅ Document Your GSIs - Comment what each GSI is used for
Common Model Patterns
Standalone Entity
pk = "product#abc-123"
sk = "product#abc-123"
Child in One-to-Many
pk = "order#xyz-789" # From parent
sk = "item#item-001" # This entity
List All of Type (GSI)
gsi_pk = "products" # Static
gsi_sk = "name#Widget"
Search by Category (GSI)
gsi_pk = "category#electronics"
gsi_sk = "product#abc-123"
What's Next?
Now that you know how to define robust models, the next step is building service layers to interact with them. In my next post, "Building Service Layers for DynamoDB Applications", I'll show you how to create clean, testable services that use these models to perform CRUD operations and business logic.
Get Started
boto3-assist is open source and available on PyPI:
pip install boto3-assist
Check out the complete model definition guide in the documentation.
About the Author: Eric Wilson is the founder of Geek Cafe and created boto3-assist to bring clean, maintainable patterns to AWS development.