Disenar un Schema Single-Table de DynamoDB para Apps
Disenar un schema single-table de DynamoDB con composite keys, patrones de GSI y access patterns para aplicaciones serverless usando Python y boto3.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Descripcion general
El diseno single-table de DynamoDB pone todas las entidades (usuarios, ordenes, productos) en una tabla usando composite keys (PK + SK) y GSIs para satisfacer todos los access patterns. Esto contrasta con el diseno relacional (una tabla por entidad) y es contraintuitivo, pero minimiza lecturas, reduce costos y habilita agregaciones de una sola query. A continuacion: disenar access patterns, construir composite keys, usar GSIs y consultar con Python/boto3.
Cuando Usar Esto
-
For alternatives, see Complete Guide to Serverless Databases.
-
Aplicaciones serverless en AWS que necesitan almacenamiento NoSQL
-
Workloads con access patterns conocidos (listalos antes de disenar)
-
Aplicaciones que necesitan lecturas de baja latencia a cualquier escala
-
Proyectos sensibles a costo (single-table minimiza la capacidad de lectura)
Prerrequisitos
- Python 3.10+
- Cuenta AWS con acceso a DynamoDB
- Paquete
boto3
Solucion
1. Definir Access Patterns
Antes de escribir codigo, lista cada query que tu aplicacion necesita:
Access Patterns:
1. Get user by ID → PK=USER#<id>, SK=PROFILE
2. Get all orders for a user → PK=USER#<id>, SK begins_with ORDER#
3. Get order by ID → PK=ORDER#<id>, SK=DETAILS
4. Get all items in an order → PK=ORDER#<id>, SK begins_with ITEM#
5. Get orders by status (e.g., PENDING) → GSI1PK=ORDERSTATUS#PENDING, GSI1SK=ORDER#<id>
6. Get orders by date range → GSI1PK=ORDERBYDATE, GSI1SK between dates
7. Get product by ID → PK=PRODUCT#<id>, SK=DETAILS
8. Get products by category → GSI1PK=CATEGORY#<cat>, GSI1SK=PRODUCT#<id>
2. Crear la Tabla
import boto3
dynamodb = boto3.client('dynamodb', region_name='us-east-1')
response = dynamodb.create_table(
TableName='AppTable',
KeySchema=[
{'AttributeName': 'PK', 'KeyType': 'HASH'},
{'AttributeName': 'SK', 'KeyType': 'RANGE'},
],
AttributeDefinitions=[
{'AttributeName': 'PK', 'AttributeType': 'S'},
{'AttributeName': 'SK', 'AttributeType': 'S'},
{'AttributeName': 'GSI1PK', 'AttributeType': 'S'},
{'AttributeName': 'GSI1SK', 'AttributeType': 'S'},
],
GlobalSecondaryIndexes=[
{
'IndexName': 'GSI1',
'KeySchema': [
{'AttributeName': 'GSI1PK', 'KeyType': 'HASH'},
{'AttributeName': 'GSI1SK', 'KeyType': 'RANGE'},
],
'Projection': {'ProjectionType': 'ALL'},
},
],
BillingMode='PAY_PER_REQUEST',
)
print(f"Table created: {response['TableDescription']['TableName']}")
3. Patrones de Entidad
import boto3
from datetime import datetime
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('AppTable')
# Entidad User
def put_user(user_id: str, name: str, email: str):
table.put_item(Item={
'PK': f'USER#{user_id}',
'SK': 'PROFILE',
'entityType': 'User',
'userId': user_id,
'name': name,
'email': email,
'createdAt': datetime.utcnow().isoformat(),
})
# Entidad Order
def put_order(order_id: str, user_id: str, status: str, total: float):
table.put_item(Item={
'PK': f'USER#{user_id}',
'SK': f'ORDER#{order_id}',
'GSI1PK': f'ORDERSTATUS#{status}',
'GSI1SK': f'ORDER#{order_id}',
'entityType': 'Order',
'orderId': order_id,
'userId': user_id,
'status': status,
'total': total,
'createdAt': datetime.utcnow().isoformat(),
})
# Entidad OrderItem (anidada bajo order)
def put_order_item(order_id: str, item_id: str, product_id: str, quantity: int, price: float):
table.put_item(Item={
'PK': f'ORDER#{order_id}',
'SK': f'ITEM#{item_id}',
'entityType': 'OrderItem',
'itemId': item_id,
'orderId': order_id,
'productId': product_id,
'quantity': quantity,
'price': price,
})
# Entidad Product
def put_product(product_id: str, category: str, name: str, price: float):
table.put_item(Item={
'PK': f'PRODUCT#{product_id}',
'SK': 'DETAILS',
'GSI1PK': f'CATEGORY#{category}',
'GSI1SK': f'PRODUCT#{product_id}',
'entityType': 'Product',
'productId': product_id,
'category': category,
'name': name,
'price': price,
})
4. Query de Access Patterns
# AP1: Get user by ID
def get_user(user_id: str) -> dict:
response = table.get_item(
Key={'PK': f'USER#{user_id}', 'SK': 'PROFILE'}
)
return response.get('Item')
# AP2: Get all orders for a user
def get_user_orders(user_id: str) -> list:
response = table.query(
KeyConditionExpression='PK = :pk AND begins_with(SK, :sk)',
ExpressionAttributeValues={
':pk': f'USER#{user_id}',
':sk': 'ORDER#',
},
)
return response.get('Items', [])
# AP3: Get order details + items en una query
def get_order_with_items(order_id: str) -> dict:
response = table.query(
KeyConditionExpression='PK = :pk',
ExpressionAttributeValues={':pk': f'ORDER#{order_id}'},
)
items = response.get('Items', [])
order = next((i for i in items if i.get('entityType') == 'Order'), None)
order_items = [i for i in items if i.get('entityType') == 'OrderItem']
return {'order': order, 'items': order_items}
# AP5: Get orders by status (usando GSI1)
def get_orders_by_status(status: str) -> list:
response = table.query(
IndexName='GSI1',
KeyConditionExpression='GSI1PK = :pk',
ExpressionAttributeValues={':pk': f'ORDERSTATUS#{status}'},
)
return response.get('Items', [])
# AP8: Get products by category (usando GSI1)
def get_products_by_category(category: str) -> list:
response = table.query(
IndexName='GSI1',
KeyConditionExpression='GSI1PK = :pk',
ExpressionAttributeValues={':pk': f'CATEGORY#{category}'},
)
return response.get('Items', [])
5. Writes Transaccionales
def create_order_with_items(user_id: str, order_id: str, items: list, total: float):
transact_items = [{
'Put': {
'TableName': 'AppTable',
'Item': {
'PK': f'USER#{user_id}',
'SK': f'ORDER#{order_id}',
'GSI1PK': 'ORDERSTATUS#PENDING',
'GSI1SK': f'ORDER#{order_id}',
'entityType': 'Order',
'orderId': order_id,
'userId': user_id,
'status': 'PENDING',
'total': total,
'createdAt': datetime.utcnow().isoformat(),
},
}
}]
for item in items:
transact_items.append({
'Put': {
'TableName': 'AppTable',
'Item': {
'PK': f'ORDER#{order_id}',
'SK': f'ITEM#{item["id"]}',
'entityType': 'OrderItem',
'itemId': item['id'],
'orderId': order_id,
'productId': item['product_id'],
'quantity': item['quantity'],
'price': item['price'],
},
}
})
dynamodb.meta.client.transact_write_items(TransactItems=transact_items)
6. Update con Condition
def update_order_status(order_id: str, user_id: str, new_status: str):
table.update_item(
Key={
'PK': f'USER#{user_id}',
'SK': f'ORDER#{order_id}',
},
UpdateExpression='SET #status = :status, GSI1PK = :gsi_pk',
ConditionExpression='attribute_exists(PK) AND attribute_exists(SK)',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={
':status': new_status,
':gsi_pk': f'ORDERSTATUS#{new_status}',
},
)
Como Funciona
- Composite keys:
PK(partition key) determina en que particion vive el item.SK(sort key) determina el orden dentro de la particion. Juntos forman un identificador unico y habilitan range queries. - Colocacion de entidades: Entidades relacionadas comparten el mismo PK. El perfil y las ordenes de un usuario tienen
PK=USER#<id>. Una query obtiene todo — sin joins. - GSI para access patterns alternativos: La tabla principal soporta queries por PK. Para queries por una dimension diferente (status, categoria), un GSI remapea los datos con un PK/SK diferente.
- begins_with para queries jerarquicos: Valores de
SKcomoORDER#123,ORDER#456pueden consultarse conbegins_with(SK, 'ORDER#')para obtener todas las ordenes de un usuario. - Ventaja single-table: Todas las entidades en una tabla significa una unidad de billing, un conjunto de settings de capacidad y la habilidad de obtener datos relacionados en una sola query.
Variantes
Sparse GSI
Solo items con un atributo especifico aparecen en el GSI, reduciendo costo:
# Solo productos publicados tienen GSI2PK — los drafts se excluyen del GSI
def put_product(product_id: str, category: str, name: str, price: float, published: bool):
item = {
'PK': f'PRODUCT#{product_id}',
'SK': 'DETAILS',
'GSI1PK': f'CATEGORY#{category}',
'GSI1SK': f'PRODUCT#{product_id}',
'entityType': 'Product',
'productId': product_id,
'name': name,
'price': price,
}
if published:
item['GSI2PK'] = 'PUBLISHED'
item['GSI2SK'] = f'PRODUCT#{product_id}'
table.put_item(Item=item)
GSI Overloaded
Usar un GSI para multiples access patterns variando el prefijo del GSI PK:
GSI1PK=ORDERSTATUS#PENDING → ordenes por status
GSI1PK=CATEGORY#electronics → productos por categoria
GSI1PK=USEREMAIL#alice@test.com → usuario por email
Queries Basadas en Tiempo
# Get ordenes en un rango de fechas usando GSI
def get_orders_by_date_range(start: str, end: str) -> list:
response = table.query(
IndexName='GSI1',
KeyConditionExpression='GSI1PK = :pk AND GSI1SK BETWEEN :start AND :end',
ExpressionAttributeValues={
':pk': 'ORDERBYDATE',
':start': f'DATE#{start}',
':end': f'DATE#{end}',
},
)
return response.get('Items', [])
Mejores Practicas
- Listar access patterns primero: DynamoDB es query-driven, no schema-driven. Si no puedes listar tus access patterns, no puedes disenar la tabla.
- Usar
begins_withpara SKs jerarquicos:USER#<id>como PK,ORDER#<id>como SK te permite consultar todas las ordenes de un usuario en una llamada. - Minimizar GSIs: Cada GSI cuesta capacidad de read/write y almacenamiento. Usa GSIs overloaded (un GSI, multiples access patterns) para reducir el conteo.
- Usar
PAY_PER_REQUESTpara workloads impredecibles: On-demand billing evita planificacion de capacidad. Cambia a provisioned para trafico estable y alto. - Mantener items bajo 400KB: El limite de item de DynamoDB es 400KB. Para datos mas grandes, divide en multiples items o usa S3 con un puntero.
- Usar transacciones con moderacion:
transact_write_itemscuesta 2x capacidad de write y tiene un limite de 100 items. Usa solo cuando se requiere atomicidad.
Errores Comunes
- Disenar como base de datos relacional: DynamoDB no soporta JOINs. Si necesitas joins, desnormaliza o usa multiples queries.
- Demasiados GSIs: Cada GSI replica datos y cuesta capacidad. Apunta a 1-2 GSIs por tabla, overloaded para multiples patrones.
- No planear access patterns: Agregar un nuevo access pattern despues del despliegue requiere un nuevo GSI, que toma tiempo y cuesta dinero.
- Usar scan en lugar de query:
scanlee toda la tabla.queryusa indices. Nunca usesscanen produccion para lecturas filtradas. - Particiones hot: Si todos los writes van a un PK (ej., un contador), esa particion se vuelve un bottleneck. Usa sufijos aleatorios para distribuir writes.
FAQ
Por que single-table en lugar de una tabla por entidad?
El diseno single-table coloca entidades relacionadas, habilitando lecturas de una sola query (sin joins). Reduce read capacity units y simplifica billing. El tradeoff es una curva de aprendizaje mas pronunciada y menos flexibilidad para nuevos access patterns.
Cuantos GSIs deberia crear?
Apunta a 1-3 GSIs. Cada GSI tiene su propia capacidad de read/write y costo de almacenamiento. Usa GSIs overloaded (diferentes prefijos de PK para diferentes access patterns) para servir multiples queries con un indice.
Puedo cambiar el schema de una tabla despues de crearla?
Puedes agregar GSIs (toma tiempo, cuesta capacidad durante la creacion). No puedes cambiar el key schema de la tabla principal. Planea access patterns antes de crear la tabla.
Cual es la diferencia entre LSI y GSI?
LSI (Local Secondary Index) tiene el mismo PK que la tabla principal pero un SK diferente. Limitado a 10GB por particion. GSI (Global Secondary Index) puede tener PK y SK diferentes. Usa LSI para ordenes de sort alternativos dentro de la misma particion, GSI para access patterns completamente diferentes.
Como manejo paginacion?
Usa ExclusiveStartKey con el LastEvaluatedKey de la query anterior:
response = table.query(
KeyConditionExpression='PK = :pk',
ExpressionAttributeValues={':pk': f'USER#{user_id}'},
Limit=20,
)
last_key = response.get('LastEvaluatedKey')
# Siguiente pagina: pasa ExclusiveStartKey=last_key
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
Recursos Relacionados
Package Python Dependencies for AWS Lambda with Layers
Package Python dependencies for AWS Lambda using Lambda Layers, Docker builds for native extensions, and SAM/Serverless Framework integration.
RecipeReduce AWS Lambda Cold Start with Provisioned Concurrency
Minimize Lambda cold start latency using provisioned concurrency, ARM64 Graviton, lighter dependencies, and initialization code optimization.
GuideServerless Architecture — Patterns and Anti-Patterns
A practical guide to serverless architecture: function design, cold starts, event-driven patterns, state management, and common pitfalls with AWS Lambda, Azure Functions, and GCP Cloud Functions.
GuideComplete Guide to Mobile Responsive Design
Build responsive layouts that work on every device. Covers CSS Grid, Flexbox, container queries, fluid typography, mobile-first breakpoints, and responsive images.
RecipeEvent-Driven Lambda with SQS Triggers and Batch Processing
Process SQS messages with Lambda using batch windows, partial batch responses, error handling, and dead-letter queues for resilient event-driven pipelines.
RecipeOrchestrate Serverless Workflows with AWS Step Functions
Build state machine workflows with AWS Step Functions using sequential, parallel, and map states for orchestrating Lambda functions and long-running processes.
PatternServerless Event Sourcing Pattern
Store function state as an append-only event log so workflows can be replayed, audited, and recovered without a persistent database.