| name | dynantic |
| description | Use this skill when the user explicitly asks to use dynantic or when you see `from dynantic import` in existing code. Dynantic is a type-safe DynamoDB ORM built on Pydantic v2. This skill covers model definition, CRUD operations, querying, filtering, pagination, conditional writes, atomic updates, batch operations, ACID transactions, TTL fields, auto-UUID keys, polymorphism (single-table design), and FastAPI integration patterns. Do NOT trigger this skill for general DynamoDB questions — only when dynantic is specifically mentioned or already in use in the codebase. |
Dynantic — Type-Safe DynamoDB ORM
Dynantic wraps boto3's low-level DynamoDB client with Pydantic v2 validation. It's synchronous-first, designed for AWS Lambda and FastAPI.
Install: pip install dynantic
Requires: Python 3.10+, pydantic >= 2.6.0, boto3 >= 1.34.0
Model Definition
Every model extends DynamoModel and declares a Meta with table_name. Use Key() for partition key, SortKey() for optional sort key.
from datetime import datetime
from decimal import Decimal
from pydantic import EmailStr
from dynantic import DynamoModel, Key, SortKey
class User(DynamoModel):
email: str = Key()
created_at: str = SortKey()
name: str
age: int
balance: Decimal = Decimal("0")
tags: set[str] = set()
verified: bool = False
class Meta:
table_name = "users"
region = "eu-west-1"
Dynantic does NOT create tables or manage schema migrations — use Terraform, CDK, or awslocal for that. The model defines how Python interacts with an existing table.
Field Types
| Python Type | DynamoDB Type | Notes |
|---|
str | S | |
int, float, Decimal | N | Use Decimal for money |
bool | BOOL | |
bytes | B | |
datetime, date | S | ISO 8601 string |
UUID | S | String representation |
Enum | S | Uses .value |
list[T] | L | Nested types supported |
dict[str, T] | M | Map type |
set[str] | SS | String set |
set[int] | NS | Number set |
Key Fields
from dynantic import Key, SortKey, GSIKey, GSISortKey, Discriminator, TTL
class MyModel(DynamoModel):
pk: str = Key()
sk: str = SortKey()
gsi_pk: str = GSIKey(index_name="MyIndex")
gsi_sk: str = GSISortKey(index_name="MyIndex")
entity_type: str = Discriminator()
expires_at: datetime = TTL()
You can define multiple GSIs by using different index_name values. Each GSI needs its own GSIKey and optionally a GSISortKey.
Auto-UUID Keys
Use Key(auto=True) or SortKey(auto=True) to auto-generate UUID4 values on instantiation. The field should be typed as UUID.
from uuid import UUID
from dynantic import DynamoModel, Key, SortKey
class Product(DynamoModel):
product_id: UUID = Key(auto=True)
name: str
price: float
class Meta:
table_name = "products"
class AuditLog(DynamoModel):
log_id: UUID = Key(auto=True)
entry_id: UUID = SortKey(auto=True)
action: str
class Meta:
table_name = "audit_logs"
Auto-UUID works with all write paths: save(), create(), batch_save(), transact_save().
TTL (Time To Live) Fields
Mark a field with TTL() to enable automatic DynamoDB TTL handling. Use datetime (recommended, auto-converted to epoch seconds on save and back on read) or int (raw epoch, passed through as-is).
from datetime import datetime, timedelta, timezone
from dynantic import TTL, DynamoModel, Key
class Session(DynamoModel):
session_id: str = Key()
user_id: str
expires_at: datetime = TTL()
class Meta:
table_name = "sessions"
session = Session(
session_id="sess-abc123",
user_id="user-42",
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
)
session.save()
retrieved = Session.get("sess-abc123")
print(isinstance(retrieved.expires_at, datetime))
TTL serialization works across all write paths: save(), batch_save(), batch_writer(), transact_save().
TTL must also be enabled on the DynamoDB table itself (via Terraform/CLI) — dynantic only handles the serialization.
CRUD Operations
Create (INSERT Semantics)
create() instantiates and saves with a condition that the PK must not exist — true INSERT behavior. Ideal with auto-UUID keys.
product = Product.create(name="Widget", price=29.99)
print(product.product_id)
user = User.create(email="test@example.com", created_at="2024-01-01", name="Test", age=25)
try:
Product.create(product_id=existing_id, name="Duplicate", price=0.0)
except ConditionalCheckFailedError:
print("Duplicate blocked")
Save (Create / Overwrite)
user = User(email="alice@example.com", created_at="2024-01-01", name="Alice", age=30)
user.save()
user.save(condition=Attr("email").not_exists())
save() does a full PutItem — it overwrites if the key already exists unless you add a condition.
Get (Single Item)
user = User.get("alice@example.com")
user = User.get("alice@example.com", "2024-01-01")
if user is None:
print("Not found")
Delete
User.delete("alice@example.com", "2024-01-01")
User.delete("alice@example.com", "2024-01-01", condition=Attr("status") == "inactive")
user.delete_item()
user.delete_item(condition=Attr("version") == 3)
Query Builder
Queries require a partition key value. Sort key conditions and filters are optional. Queries are lazy — nothing hits DynamoDB until you call a terminal method.
orders = Order.query("user-123").all()
recent = Order.query("user-123").gt("2024-01-01").all()
range_ = Order.query("user-123").between("2024-01-01", "2024-12-31").all()
prefix = Order.query("user-123").starts_with("2024-").all()
latest = Order.query("user-123").reverse().first()
top5 = Order.query("user-123").reverse().limit(5).all()
Sort Key Condition Methods
These are called directly on the query builder after .query(pk):
| Method | DynamoDB Expression |
|---|
.eq(value) | SK = :val |
.ne(value) | SK <> :val |
.gt(value) | SK > :val |
.ge(value) | SK >= :val |
.lt(value) | SK < :val |
.le(value) | SK <= :val |
.between(low, high) | SK BETWEEN :low AND :high |
.starts_with(prefix) | begins_with(SK, :prefix) |
Terminal Methods
| Method | Returns | Behavior |
|---|
.all() | list[T] | All matching items (auto-paginates) |
.first() | T | None | First match or None |
.one() | T | Exactly one match, raises if 0 or >1 |
.page(start_key=None) | PageResult[T] | Single page with cursor |
for item in query: | T | Lazy iteration with auto-pagination |
Filtering (Post-Query)
Filters run after DynamoDB retrieves items — you still pay RCUs for all scanned items. Use key conditions for efficiency, filters for refinement.
from dynantic import Attr
premium = User.query("org-1").filter(Attr("tier") == "premium").all()
results = (Order.query("user-1")
.gt("2024-01-01")
.filter(Attr("status") == "shipped")
.filter(Attr("total") > 100)
.all())
condition = (Attr("rating") >= 4.0) | (Attr("featured") == True)
movies = Movie.query(2024).filter(condition).all()
Conditions DSL
Attr() builds conditions for both filter expressions and conditional writes.
from dynantic import Attr
Attr("age") == 30
Attr("age") != 30
Attr("age") > 18
Attr("age") >= 18
Attr("age") < 65
Attr("age") <= 65
Attr("email").exists()
Attr("temp").not_exists()
Attr("name").begins_with("A")
Attr("tags").contains("vip")
Attr("age").between(18, 65)
Attr("status").is_in(["active", "pending"])
(Attr("age") >= 18) & (Attr("status") == "active")
(Attr("tier") == "gold") | (Attr("tier") == "platinum")
~Attr("banned").exists()
Operator equivalents
Attr also provides named method equivalents: .eq(), .ne(), .gt(), .gte(), .lt(), .lte().
Atomic Updates
Update items without fetching them first. Saves RCUs and avoids race conditions.
User.update("alice@example.com", "2024-01-01") \
.set(User.name, "Alice Smith") \
.execute()
user.patch() \
.set(User.name, "Alice Smith") \
.execute()
Update Actions
builder = User.update("alice@example.com", "2024-01-01")
builder.set(User.name, "New Name")
builder.add(User.login_count, 1)
builder.add(User.tags, {"new-tag"})
builder.remove(User.temp_field)
builder.delete(User.tags, {"old-tag"})
Chaining & Options
updated = User.update("alice@example.com", "2024-01-01") \
.set(User.status, "verified") \
.add(User.login_count, 1) \
.remove(User.temp_code) \
.condition(Attr("version") == 5)
.return_values("ALL_NEW")
.execute()
return_values options: "NONE", "ALL_OLD", "UPDATED_OLD", "ALL_NEW", "UPDATED_NEW".
Batch Operations
Batch operations handle auto-chunking (25 for writes, 100 for reads) and retry unprocessed items with exponential backoff — no manual retry logic needed.
batch_save
users = [User(user_id=f"u{i}", name=f"User {i}", email=f"u{i}@test.com", age=20+i) for i in range(50)]
User.batch_save(users)
batch_get
keys = [{"user_id": f"u{i}"} for i in range(50)]
users = User.batch_get(keys)
sorted_users = sorted(users, key=lambda u: u.user_id)
batch_delete
keys = [{"user_id": f"u{i}"} for i in range(25)]
User.batch_delete(keys)
batch_writer (Context Manager)
Mix saves and deletes in a single batch context. Auto-flushes every 25 items and on context exit. Does not flush on exception.
with User.batch_writer() as batch:
for i in range(100):
batch.save(User(user_id=f"u{i}", name=f"User {i}", email=f"u{i}@test.com", age=25))
for i in range(200, 210):
batch.delete(user_id=f"u{i}")
Transactions
ACID transactions across one or multiple DynamoDB tables. Limited to 100 items per transaction (DynamoDB constraint). Dynantic validates this limit and raises ValidationError if exceeded.
transact_save (Simple)
Atomically saves multiple items. All succeed or all fail. Works across different model classes (cross-table).
from dynantic import DynamoModel
alice = Account(account_id="acc-1", owner="Alice", balance=1000.0)
bob = Account(account_id="acc-2", owner="Bob", balance=500.0)
transfer = Transfer(transfer_id="tx-001", from_account="acc-1", to_account="acc-2", amount=200.0)
DynamoModel.transact_save([alice, bob, transfer])
transact_write (Advanced)
Supports mixed actions: TransactPut, TransactDelete, TransactConditionCheck.
from dynantic import Attr, DynamoModel, TransactPut, TransactDelete, TransactConditionCheck
DynamoModel.transact_write([
TransactPut(
Account(account_id="acc-1", owner="Alice", balance=800.0),
condition=(Attr("active") == True) & (Attr("balance") >= 200),
),
TransactPut(Account(account_id="acc-2", owner="Bob", balance=700.0)),
TransactPut(transfer),
TransactDelete(
OldRecord,
condition=Attr("status") == "archived",
record_id="old-001",
),
])
TransactConditionCheck asserts a condition on an item without modifying it — useful for cross-item validation:
DynamoModel.transact_write([
TransactPut(new_order),
TransactConditionCheck(User, Attr("status").eq("active"), user_id="u1"),
])
transact_get (Consistent Reads)
Atomically reads multiple items for a consistent snapshot.
from dynantic import DynamoModel, TransactGet
results = DynamoModel.transact_get([
TransactGet(Account, account_id="acc-1"),
TransactGet(Account, account_id="acc-2"),
TransactGet(Transfer, transfer_id="tx-001"),
])
alice, bob, transfer = results
if alice is None:
print("Account not found")
Scanning
Full-table scans are expensive but sometimes necessary. Same filter/terminal API as queries.
all_users = User.scan().all()
active = User.scan().filter(Attr("status") == "active").all()
User.scan(index_name="StatusIndex").filter(Attr("status") == "active").all()
page = User.scan().limit(50).page()
GSI Queries
Define GSI fields in your model, then query them with query_index().
class Employee(DynamoModel):
employee_id: str = Key()
name: str
department: str = GSIKey(index_name="DepartmentIndex")
hire_date: str = GSISortKey(index_name="DepartmentIndex")
location: str = GSIKey(index_name="LocationIndex")
class Meta:
table_name = "employees"
engineers = Employee.query_index("DepartmentIndex", "Engineering") \
.gt("2024-01-01") \
.all()
sf_employees = Employee.query_index("LocationIndex", "San Francisco").all()
The GSI must exist on the actual DynamoDB table — dynantic doesn't create it.
Pagination
For stateless APIs (REST/GraphQL), use .page() to get one page at a time with a cursor.
from dynantic import PageResult
page: PageResult[Order] = Order.query("user-1").limit(20).page()
if page.has_more:
page2 = Order.query("user-1").limit(20).page(start_key=page.last_evaluated_key)
Also works with scan():
page = User.scan().limit(50).page()
page = User.scan().limit(50).page(start_key=page.last_evaluated_key)
Client Management
Dynantic uses a global singleton boto3 client by default. Override it for testing or custom configuration.
import boto3
from botocore.config import Config
from dynantic import DynamoModel
client = boto3.client(
"dynamodb",
endpoint_url="http://localhost:4566",
region_name="eu-west-1",
)
DynamoModel.set_client(client)
with User.using_client(test_client):
user = User.get("test-key")
Exception Handling
from dynantic.exceptions import (
ConditionalCheckFailedError,
TableNotFoundError,
TransactionConflictError,
ProvisionedThroughputExceededError,
ValidationError,
)
try:
user.save(condition=Attr("version") == expected)
except ConditionalCheckFailedError:
pass
except TableNotFoundError:
pass
try:
DynamoModel.transact_write([...])
except TransactionConflictError:
pass
All exceptions inherit from DynanticError and carry the original_error (the underlying ClientError).
Logging
import logging
logging.getLogger("dynantic").setLevel(logging.DEBUG)
logging.getLogger("dynantic").setLevel(logging.INFO)
Common Pitfalls
- Empty sets — DynamoDB doesn't allow empty sets. Don't save
set(). Use None or omit the field.
- Float precision — Use
Decimal for financial values, not float.
- Filter != Key condition — Filters don't reduce RCU cost. Design your keys for your access patterns.
- save() overwrites — It's a PutItem. Use
condition=Attr("pk").not_exists() for create-only, or use create().
- Table must exist — Dynantic doesn't create tables. Use Terraform or
awslocal.
- Async — Dynantic is synchronous. In FastAPI, wrap with
asyncio.to_thread() or use sync endpoints.
- batch_get order — DynamoDB does not guarantee order in batch_get responses. Sort after fetching if needed.
- Transaction limits — Max 100 items per transaction. Dynantic validates this and raises
ValidationError.
Advanced Patterns
For polymorphism (single-table design), FastAPI integration, and testing patterns, read references/advanced-patterns.md.
For a compact API reference table, read references/api-quick-ref.md.