PydamoDB is a lightweight Python library that gives your Pydantic models DynamoDB superpowers. If you're already using Pydantic for data validation and want a simple, intuitive way to persist your models to DynamoDB, this library is for you.
⚠️ API Stability WarningPydamoDB is under active development and the API may change significantly between versions. We recommend pinning to a specific version in your dependencies to avoid breaking changes:
pip install pydamodb==0.1.0 # Pin to a specific versionOr in your
pyproject.toml:dependencies = [ "pydamodb==0.1.0", # Pin to a specific version ]
- 🔄 Seamless Pydantic Integration - Your models remain valid Pydantic models with all their features intact.
- 🔑 Automatic Key Schema Detection - Reads partition/sort key configuration directly from your DynamoDB table.
- 📝 Conditional Writes - Support for conditional save, update, and delete operations.
- 🔍 Query Support - Query by partition key with sort key conditions and filters with built-in pagination.
- 🗂️ Index Support - Query Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI).
- ⚡ Async Support - Full async/await support via
aioboto3for high-performance applications.
These are some limitations to be aware of:
- Float attributes: DynamoDB doesn't support floats. Use
Decimalinstead or a custom serializer. - Key schema: Field names for partition/sort keys must match the table's key schema exactly.
- Transactions: Multi-item transactions are not supported.
- Scan operations: Full table scans are intentionally not exposed.
- Batch reads: Batch get operations are not supported.
- Update expressions: Only
SETupdates are supported. ForADD,REMOVE, orDELETE, read-modify-save the full item.
This library IS for you if:
- You're already using Pydantic and want to persist models to DynamoDB.
- You want a simple, intuitive API without complex configuration.
- You prefer convention over configuration.
This library is NOT for you if:
- You need low-level DynamoDB control.
- You need a full-featured ODM (consider PynamoDB instead).
- You need complex multi-item transactions.
pip install pydamodbNote: PydamoDB requires boto3 for sync operations or aioboto3 for async operations. Since PydamoDB doesn't directly import those dependencies, you must install and manage your own version:
# For synchronous operations
pip install boto3
# For asynchronous operations
pip install aioboto3
# Or both
pip install boto3 aioboto3PydamoDB provides two base model classes for different table key configurations:
Use for tables with only a partition key:
from pydamodb import PrimaryKeyModel
class Character(PrimaryKeyModel):
name: str # Partition key
age: int
occupation: strUse for tables with both partition key and sort key:
from pydamodb import PrimaryKeyAndSortKeyModel
class FamilyMember(PrimaryKeyAndSortKeyModel):
family: str # Partition key
name: str # Sort key
age: int
occupation: strFor async operations, use the async equivalents:
AsyncPrimaryKeyModel(alias:AsyncPKModel)AsyncPrimaryKeyAndSortKeyModel(alias:AsyncPKSKModel)
Each model requires a pydamo_config class variable with the DynamoDB table resource. Both sync and async models use the same PydamoConfig class:
Sync:
import boto3
from pydamodb import PrimaryKeyModel, PydamoConfig
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("characters")
class Character(PrimaryKeyModel):
pydamo_config = PydamoConfig(table=table)
name: str
age: int
occupation: strAsync:
import aioboto3
from pydamodb import AsyncPrimaryKeyModel, PydamoConfig
async def setup():
session = aioboto3.Session()
async with session.resource("dynamodb") as dynamodb:
table = await dynamodb.Table("characters")
class Character(AsyncPrimaryKeyModel):
pydamo_config = PydamoConfig(table=table)
name: str
age: int
occupation: strPydamoDB automatically reads the key schema from the table to determine which fields are partition/sort keys.
Save a model instance to DynamoDB.
Sync:
homer = Character(name="Homer", age=39, occupation="Safety Inspector")
homer.save()Async:
homer = Character(name="Homer", age=39, occupation="Safety Inspector")
await homer.save()With conditions:
from botocore.exceptions import ClientError
from pydamodb import PydamoError
try:
# Only save if the item doesn't exist
homer.save(condition=Character.attr.name.not_exists())
except ClientError as e:
# Handle boto3 ConditionalCheckFailedException
print(f"Condition failed: {e}")Retrieve an item by its key.
Sync:
# Partition key only table
character = Character.get_item("Homer")
if character is None:
print("Character not found")
# With consistent read
character = Character.get_item("Homer", consistent_read=True)Async:
# Partition key only table
character = await Character.get_item("Homer")
if character is None:
print("Character not found")
# With consistent read
character = await Character.get_item("Homer", consistent_read=True)For tables with partition key + sort key:
Sync:
member = FamilyMember.get_item("Simpson", "Homer")Async:
member = await FamilyMember.get_item("Simpson", "Homer")Update specific fields of an item.
Sync:
# Update a single field
Character.update_item("Homer", updates={Character.attr.age: 40})
# Update multiple fields
Character.update_item(
"Homer",
updates={
Character.attr.age: 40,
Character.attr.catchphrase: "Woo-hoo!",
},
)
# Conditional update
Character.update_item(
"Homer",
updates={Character.attr.occupation: "Astronaut"},
condition=Character.attr.occupation == "Safety Inspector",
)Async:
# Update a single field
await Character.update_item("Homer", updates={Character.attr.age: 40})
# Update multiple fields
await Character.update_item(
"Homer",
updates={
Character.attr.age: 40,
Character.attr.catchphrase: "Woo-hoo!",
},
)
# Conditional update
await Character.update_item(
"Homer",
updates={Character.attr.occupation: "Astronaut"},
condition=Character.attr.occupation == "Safety Inspector",
)For tables with partition key + sort key:
Sync:
FamilyMember.update_item("Simpson", "Homer", updates={FamilyMember.attr.age: 40})Async:
await FamilyMember.update_item("Simpson", "Homer", updates={FamilyMember.attr.age: 40})Delete an item from DynamoDB.
Sync:
# Delete by instance
character = Character.get_item("Homer")
if character:
character.delete()
# Delete by key
Character.delete_item("Homer")
# Conditional delete
Character.delete_item("Homer", condition=Character.attr.age > 50)Async:
# Delete by instance
character = await Character.get_item("Homer")
if character:
await character.delete()
# Delete by key
await Character.delete_item("Homer")
# Conditional delete
await Character.delete_item("Homer", condition=Character.attr.age > 50)For tables with partition key + sort key:
Sync:
FamilyMember.delete_item("Simpson", "Homer")Async:
await FamilyMember.delete_item("Simpson", "Homer")Query items by partition key (only available for PrimaryKeyAndSortKeyModel / AsyncPrimaryKeyAndSortKeyModel).
Sync:
# Get all members of a family
result = FamilyMember.query("Simpson")
for member in result.items:
print(member.name, member.occupation)
# With sort key condition
result = FamilyMember.query(
"Simpson",
sort_key_condition=FamilyMember.attr.name.begins_with("B"),
)
# With filter condition
result = FamilyMember.query(
"Simpson",
filter_condition=FamilyMember.attr.age < 18,
)
# With limit
result = FamilyMember.query("Simpson", limit=2)
# Pagination
result = FamilyMember.query("Simpson")
while result.last_evaluated_key:
result = FamilyMember.query(
"Simpson",
exclusive_start_key=result.last_evaluated_key,
)
# Process result.items
# Get all items (handles pagination automatically)
all_simpsons = FamilyMember.query_all("Simpson")Async:
# Get all members of a family
result = await FamilyMember.query("Simpson")
for member in result.items:
print(member.name, member.occupation)
# With sort key condition
result = await FamilyMember.query(
"Simpson",
sort_key_condition=FamilyMember.attr.name.begins_with("B"),
)
# With filter condition
result = await FamilyMember.query(
"Simpson",
filter_condition=FamilyMember.attr.age < 18,
)
# With limit
result = await FamilyMember.query("Simpson", limit=2)
# Pagination
result = await FamilyMember.query("Simpson")
while result.last_evaluated_key:
result = await FamilyMember.query(
"Simpson",
exclusive_start_key=result.last_evaluated_key,
)
# Process result.items
# Get all items (handles pagination automatically)
all_simpsons = await FamilyMember.query_all("Simpson")PydamoDB wraps boto3's batch_writer so you can work directly with models.
Sync:
characters = [
Character(name="Homer", age=39, occupation="Safety Inspector"),
Character(name="Marge", age=36, occupation="Homemaker"),
]
with Character.batch_writer() as writer:
for character in characters:
writer.put(character)Async:
characters = [
Character(name="Homer", age=39, occupation="Safety Inspector"),
Character(name="Marge", age=36, occupation="Homemaker"),
]
async with Character.batch_writer() as writer:
for character in characters:
await writer.put(character)PydamoDB provides a rich set of condition expressions for conditional operations and query filters.
# Equality
Character.attr.occupation == "Safety Inspector" # Eq
Character.attr.occupation != "Teacher" # Ne
# Numeric comparisons
Character.attr.age < 18 # Lt
Character.attr.age <= 39 # Lte
Character.attr.age > 10 # Gt
Character.attr.age >= 21 # Gte
# Between (inclusive)
Character.attr.age.between(10, 50)# String begins with
Character.attr.name.begins_with("B")
# Contains (for strings or sets)
Character.attr.catchphrase.contains("D'oh")
# IN - check if value is in a list
Character.attr.occupation.in_("Student", "Teacher", "Principal")
Character.attr.age.in_(10, 38, 39, 8, 1)
# Size - compare the size/length of an attribute
Character.attr.name.size() >= 3 # String length
Character.attr.children.size() > 0 # List item count
Character.attr.traits.size() == 5 # Set element count
# Attribute existence
Character.attr.catchphrase.exists() # AttributeExists
Character.attr.retired_at.not_exists() # AttributeNotExistsCombine conditions using Python operators:
# AND - both conditions must be true
condition = (Character.attr.age >= 18) & (Character.attr.occupation == "Student")
# OR - either condition must be true
condition = (Character.attr.name == "Homer") | (Character.attr.name == "Marge")
# NOT - negate a condition
condition = ~(Character.attr.age < 18)
# Complex combinations
condition = (
(Character.attr.age >= 10)
& (Character.attr.occupation != "Baby")
& ~(Character.attr.name == "Maggie")
)Query Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI).
Sync:
class FamilyMember(PrimaryKeyAndSortKeyModel):
pydamo_config = PydamoConfig(table=family_members_table)
family: str # Table partition key
name: str # Table sort key
occupation: str # GSI partition key (occupation-index)
created_at: str # LSI sort key (created-at-index)
age: int
# Query a GSI
inspectors = FamilyMember.query(
partition_key_value="Safety Inspector",
index_name="occupation-index",
)
# Query a LSI
recent_simpsons = FamilyMember.query(
partition_key_value="Simpson",
sort_key_condition=FamilyMember.attr.created_at.begins_with("2024-"),
index_name="created-at-index",
)
# Get all items from an index
all_students = FamilyMember.query_all(
partition_key_value="Student",
index_name="occupation-index",
)Async:
# Query a GSI
inspectors = await FamilyMember.query(
partition_key_value="Safety Inspector",
index_name="occupation-index",
)
# Query a LSI
recent_simpsons = await FamilyMember.query(
partition_key_value="Simpson",
sort_key_condition=FamilyMember.attr.created_at.begins_with("2024-"),
index_name="created-at-index",
)
# Get all items from an index
all_students = await FamilyMember.query_all(
partition_key_value="Student",
index_name="occupation-index",
)Note: Consistent reads are not supported on Global Secondary Indexes.
PydamoDB provides type-safe field access through the attr descriptor:
class Character(PrimaryKeyModel):
pydamo_config = PydamoConfig(table=characters_table)
name: str
age: int
occupation: str
# Type-safe field references
Character.attr.name # ExpressionField[str]
Character.attr.age # ExpressionField[int]
# Type checking catches errors
Character.update_item(
"Homer",
updates={
Character.attr.age: "not a number", # Type error!
},
)
# Non-existent fields raise AttributeError
Character.attr.nonexistent # AttributeError: 'Character' has no field 'nonexistent'For full type inference, enable the mypy plugin:
# pyproject.toml
[tool.mypy]
plugins = ["pydamodb.mypy"]PydamoDB follows a simple exception philosophy: we only raise custom exceptions for PydamoDB-specific errors. boto3 exceptions (like ConditionalCheckFailedException, ProvisionedThroughputExceededException) and Pydantic validation errors bubble up naturally without wrapping.
This approach:
- Keeps things simple - You don't need to learn wrapped versions of familiar exceptions.
- Uses standard patterns - Handle boto3 and Pydantic exceptions the same way you always do.
- Provides clarity - Custom exceptions are only for PydamoDB-specific issues.
from pydamodb import (
PydamoError,
MissingSortKeyValueError,
InvalidKeySchemaError,
IndexNotFoundError,
InsufficientConditionsError,
UnknownConditionTypeError,
EmptyUpdateError,
)
# Catch all PydamoDB errors
try:
homer.save()
except PydamoError as e:
print(f"PydamoDB error: {e}")
# Catch specific PydamoDB errors
try:
FamilyMember.query("Simpson", index_name="nonexistent-index")
except IndexNotFoundError as e:
print(f"Index not found: {e.index_name}")
try:
FamilyMember.get_item("Simpson") # Missing sort key!
except MissingSortKeyValueError:
print("Sort key is required for this table")PydamoDB Exception Hierarchy:
PydamoError (base)
├── MissingSortKeyValueError
├── InvalidKeySchemaError
├── IndexNotFoundError
├── InsufficientConditionsError
├── UnknownConditionTypeError
└── EmptyUpdateError
Here's how to use PydamoDB with FastAPI:
from fastapi import FastAPI, HTTPException
from pydamodb import AsyncPrimaryKeyModel, PydamoConfig
from botocore.exceptions import ClientError
import aioboto3
app = FastAPI()
class Character(AsyncPrimaryKeyModel):
name: str
age: int
occupation: str
catchphrase: str | None = None
@app.on_event("startup")
async def startup():
session = aioboto3.Session()
app.state.dynamodb_session = session
async with session.resource("dynamodb") as dynamodb:
table = await dynamodb.Table("characters")
Character.pydamo_config = PydamoConfig(table=table)
@app.get("/characters/{name}")
async def get_character(name: str):
try:
character = await Character.get_item(name)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
return character
except ClientError as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/characters")
async def create_character(character: Character):
try:
await character.save(condition=Character.attr.name.not_exists())
return character
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
raise HTTPException(status_code=409, detail="Character already exists")
raise HTTPException(status_code=500, detail=str(e))If you already have Pydantic models, migrating to PydamoDB is straightforward. Your models remain valid Pydantic models with all their features intact.
| Your DynamoDB Table | Base Class to Use |
|---|---|
| Partition key only | PrimaryKeyModel or AsyncPrimaryKeyModel |
| Partition key + Sort key | PrimaryKeyAndSortKeyModel or AsyncPrimaryKeyAndSortKeyModel |
# Before: Plain Pydantic model
from pydantic import BaseModel
class Character(BaseModel):
name: str
age: int
occupation: str
catchphrase: str | None = None
# After: PydamoDB model
from pydamodb import PrimaryKeyModel, PydamoConfig
class Character(PrimaryKeyModel):
pydamo_config = PydamoConfig(table=characters_table)
name: str # Now serves as partition key
age: int
occupation: str
catchphrase: str | None = NoneYour model field names must match the attribute names in your DynamoDB table's key schema:
# If your table has partition key "name":
class Character(PrimaryKeyModel):
name: str # ✅ Must match partition key name exactly
age: int # Other fields can be named anything
occupation: strEverything you love about Pydantic continues to work:
from pydantic import field_validator, computed_field
class Character(PrimaryKeyModel):
pydamo_config = PydamoConfig(table=characters_table)
name: str
age: int
occupation: str
catchphrase: str | None = None
# ✅ Validators still work
@field_validator("age")
@classmethod
def validate_age(cls, v: int) -> int:
if v < 0:
raise ValueError("Age cannot be negative")
return v
# ✅ Computed fields still work
@computed_field
@property
def display_name(self) -> str:
return f"{self.name} ({self.occupation})"
# ✅ model_dump() works
homer = Character(name="Homer", age=39, occupation="Safety Inspector")
data = homer.model_dump()
# ✅ model_validate() works
character = Character.model_validate(
{"name": "Homer", "age": 39, "occupation": "Safety Inspector"}
)
# ✅ JSON serialization works
json_str = homer.model_dump_json()PydamoDB is designed to keep your models as valid Pydantic models. Anything that would break Pydantic functionality is avoided.
- Change base class from
BaseModeltoPrimaryKeyModel/PrimaryKeyAndSortKeyModel(or async variants) - Install
boto3(for sync) oraioboto3(for async) separately - Add
pydamo_config = PydamoConfig(table=your_table)to the class - Ensure field names for keys match your DynamoDB table's key schema
PydamoDB is built on these principles:
- Simplicity over features: We don't implement every DynamoDB feature. The API should be intuitive and easy to learn.
- Pydantic-first: Your models should remain valid Pydantic models with all their features.
- Convention over configuration: Minimize boilerplate by reading configuration from your table.
- No magic: Operations do what they say. No hidden batch operations or automatic retries.