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:

  1. Inherit from DynamoDBModelBase
  2. Call super().__init__() first
  3. Define attributes with type hints (use Optional for nullable fields)
  4. 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:

  1. Get user by ID → Primary key
  2. List all users → GSI0
  3. Find users by last name → GSI1 with begins_with
  4. 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/sk for primary, gsi0_pk/gsi0_sk for 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.

Geek Cafe LogoGeek Cafe

Your trusted partner for cloud architecture, development, and technical solutions. Let's build something amazing together.

Quick Links

© 2025 Geek Cafe LLC. All rights reserved.

Research Triangle Park, North Carolina

Version: 8.7.2