Session 034 — DynamoDB: transactions and conditional operations
Dependencies: session-033-dax-arquitetura-casos-uso
Objective
By the end of this session, you will be able to implement a TransactWriteItems that atomically updates multiple entities, use ConditionExpression for optimistic locking with a version counter, understand transaction limits (100 items, 4 MB, 2× cost), and correctly distinguish the isolation levels of each operation.
Context
[FACT] DynamoDB has offered native support for ACID transactions since November 2018. TransactWriteItems and TransactGetItems provide atomicity and serializable isolation for operations that need consistency across multiple items — without the need for client-side coordination.
[CONSENSUS] The 2× cost of transactions (each item requires two internal operations: prepare + commit) is frequently misunderstood. For multi-item transactions, this cost can be economically superior to attempting application-side consistency with retries and manual rollbacks.
Key concepts
1. TransactWriteItems: available actions and limits
[FACT] TransactWriteItems groups up to 100 write actions into a single all-or-nothing operation. The allowed actions are:
Ação Equivalente Uso típico
─────────────────────────────────────────────────────────────
Put PutItem Criar ou substituir um item
Update UpdateItem Modificar atributos
Delete DeleteItem Remover um item
ConditionCheck (sem equivalente) Verificar condição sem escrita
(ex: verificar se saldo suficiente
antes de debitar em outro item)
[FACT] Hard limits:
- Maximum 100 distinct items per transaction
- Aggregate item size: maximum 4 MB
- Only within the same region and same AWS account
- Cannot target the same item twice in the same transaction (e.g., ConditionCheck + Update on the same item → validation error)
[FACT] Transactions do not work via indexes — actions must always reference the base table via PK (+ SK if it exists).
2. 2× cost and how to calculate it
[FACT] Each item in a transaction consumes 2× the normal cost of write or read, because DynamoDB executes two internal phases (prepare and commit):
Exemplo: TransactWriteItems com 3 itens de 500 bytes cada
Cálculo normal (sem transação):
Item 1: ceil(500B / 1KB) = 1 WCU
Item 2: 1 WCU
Item 3: 1 WCU
Total: 3 WCU
Com transação (2× por item):
Item 1: 1 WCU × 2 = 2 WCU
Item 2: 1 WCU × 2 = 2 WCU
Item 3: 1 WCU × 2 = 2 WCU
Total: 6 WCU
Com transação via DAX (extra RCU para popular cache):
6 WCU (escrita) + 6 RCU (TransactGetItems pós-escrita)
Total: 6 WCU + 6 RCU
[FACT] The 2× cost appears in CloudWatch as ConsumedWriteCapacityUnits — the two internal operations (prepare and commit) are visible separately in the metrics.
3. Isolation: levels by operation table
[FACT] The isolation levels between transactions and other operation types are:
Operação concorrente Nível de isolamento
──────────────────────────────────────────────────────────────
PutItem / UpdateItem / DeleteItem SERIALIZABLE
GetItem (individual) SERIALIZABLE
TransactWriteItems / TransactGetItems SERIALIZABLE (entre si)
BatchGetItem (unidade) READ-COMMITTED *
BatchWriteItem (unidade) NÃO serializável *
Query / Scan READ-COMMITTED
* individual items within batch/query = serializable;
the batch/query as a unit = read-committed
[FACT] Serializable: the result is equivalent to executing the operations one after another, without overlap. Read-committed: the read returns only values from already-completed transactions, but may see different versions of different items if a transaction completed between reads.
[FACT] After a successful TransactWriteItems, subsequent eventually consistent reads may still return the previous state for a short period. To guarantee the most recent value immediately after the transaction, use ConsistentRead=True via the DynamoDB client (not DAX).
4. Idempotency with ClientRequestToken
[FACT] TransactWriteItems accepts an optional ClientRequestToken to guarantee idempotency in case of retries due to timeouts or connectivity issues:
Janela de idempotência: 10 minutos
├── Mesmo token, mesmos parâmetros → sucesso, sem repetição da escrita
└── Mesmo token, parâmetros diferentes → IdempotentParameterMismatch
Após 10 minutos: mesmo token é tratado como nova requisição
[FACT] If ReturnConsumedCapacity is configured: the original call returns consumed WCUs; repeated calls within the window return RCUs (read cost for idempotency verification).
5. ConditionExpression: functions and operators
[FACT] ConditionExpression can be used in PutItem, UpdateItem, DeleteItem, and in the ConditionCheck action within transactions. If the condition fails, the operation returns ConditionalCheckFailedException.
Funções disponíveis:
─────────────────────────────────────────────────────────────
attribute_exists(path) Verdadeiro se o atributo existe
attribute_not_exists(path) Verdadeiro se o atributo NÃO existe
attribute_type(path, type) Verifica tipo: S, N, B, SS, NS, BS,
M, L, NULL, BOOL
begins_with(path, substr) String começa com o valor dado
contains(path, operand) Set contém elemento / string contém substr
size(path) Tamanho do atributo (número de bytes/chars)
Operadores de comparação: = <> < <= > >=
Operadores lógicos: AND OR NOT
Operador de faixa: BETWEEN :lo AND :hi
Operador de lista: IN (:v1, :v2, ...)
[FACT] attribute_not_exists(PK) in a PutItem is the canonical mechanism for put-if-not-exists — it prevents overwriting an existing item. If the table has PK + SK, the condition checks whether the combination PK+SK does not exist.
6. Optimistic locking with version counter
[FACT] The optimistic locking pattern in DynamoDB uses a version attribute (integer) that is checked before each write and incremented along with the update:
Fluxo do optimistic locking:
1. Client A lê item: {PK: "X", version: 5, data: "abc"}
2. Client B lê item: {PK: "X", version: 5, data: "abc"}
3. Client A faz UpdateItem:
UpdateExpression: "SET data = :new, version = version + :inc"
ConditionExpression: "version = :expected"
ExpressionAttributeValues: {":new": "xyz", ":inc": 1, ":expected": 5}
→ Sucesso: item agora tem version: 6
4. Client B tenta UpdateItem com version: 5
ConditionExpression: "version = :expected" (esperava 5, encontrou 6)
→ ConditionalCheckFailedException: Client B deve reler e retentar
[CONSENSUS] Optimistic locking is preferable to pessimistic locking (explicit locks) in DynamoDB because: (a) there is no native lock mechanism; (b) actual conflicts are rare in most workloads; (c) the retry cost is lower than the overhead of lock coordination.
Practical example
Scenario: bank transfer — atomic debit and credit with balance ConditionCheck
Table: accounts-table (single-table design)
Operation: transfer value between two accounts with balance validation, versioning, and atomic auditing
CDK Python — Table and Lambda function
from aws_cdk import (
Stack, RemovalPolicy,
aws_dynamodb as dynamodb,
aws_lambda as lambda_,
aws_iam as iam,
)
from constructs import Construct
class BankingStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs):
super().__init__(scope, construct_id, **kwargs)
accounts_table = dynamodb.Table(
self, "AccountsTable",
table_name="accounts-table",
partition_key=dynamodb.Attribute(
name="PK", type=dynamodb.AttributeType.STRING
),
sort_key=dynamodb.Attribute(
name="SK", type=dynamodb.AttributeType.STRING
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
transfer_fn = lambda_.Function(
self, "TransferFn",
function_name="bank-transfer",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="handler.lambda_handler",
code=lambda_.Code.from_asset("lambda/transfer"),
)
accounts_table.grant_read_write_data(transfer_fn)
Python — Complete implementation
# lambda/transfer/handler.py
import uuid
from decimal import Decimal
from datetime import datetime, timezone
import boto3
from boto3.dynamodb.conditions import Attr
from botocore.exceptions import ClientError
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table = dynamodb.Table("accounts-table")
# ── Operações de setup (criação de conta) ─────────────────────────────
def create_account(account_id: str, owner: str, initial_balance: Decimal) -> dict:
"""
PutItem com attribute_not_exists — put-if-not-exists.
Falha com ConditionalCheckFailedException se a conta já existe.
"""
try:
table.put_item(
Item={
"PK": f"ACCOUNT#{account_id}",
"SK": "METADATA",
"accountId": account_id,
"owner": owner,
"balance": initial_balance,
"version": 0,
"status": "ACTIVE",
"createdAt": datetime.now(timezone.utc).isoformat(),
},
ConditionExpression="attribute_not_exists(PK)",
)
return {"success": True, "accountId": account_id}
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
raise ValueError(f"Account {account_id} already exists")
raise
def deactivate_account_if_empty(account_id: str) -> None:
"""
UpdateItem condicional: só desativa se saldo = 0.
"""
try:
table.update_item(
Key={"PK": f"ACCOUNT#{account_id}", "SK": "METADATA"},
UpdateExpression="SET #s = :inactive, updatedAt = :ts",
ConditionExpression="balance = :zero AND #s = :active",
ExpressionAttributeNames={"#s": "status"}, # "status" é palavra reservada
ExpressionAttributeValues={
":inactive": "INACTIVE",
":active": "ACTIVE",
":zero": Decimal("0"),
":ts": datetime.now(timezone.utc).isoformat(),
},
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
raise ValueError("Cannot deactivate: account not active or balance not zero")
raise
# ── Transferência atômica com TransactWriteItems ──────────────────────
def transfer(
from_account_id: str,
to_account_id: str,
amount: Decimal,
from_version: int,
to_version: int,
idempotency_key: str | None = None,
) -> dict:
"""
Transferência atômica com:
- ConditionCheck: valida que from_account tem saldo suficiente e está ATIVA
- Update em from_account: decrementa saldo + incrementa version
- Update em to_account: incrementa saldo + incrementa version
- Put de registro de auditoria (transação imutável)
from_version / to_version: versões lidas previamente (optimistic locking)
idempotency_key: opcional, garante idempotência por 10 minutos
"""
if amount <= 0:
raise ValueError("Amount must be positive")
transfer_id = str(uuid.uuid4())
ts = datetime.now(timezone.utc).isoformat()
try:
kwargs = {
"TransactItems": [
# 1. ConditionCheck: saldo suficiente + conta ativa + versão correta
{
"ConditionCheck": {
"TableName": "accounts-table",
"Key": {
"PK": {"S": f"ACCOUNT#{from_account_id}"},
"SK": {"S": "METADATA"},
},
"ConditionExpression": (
"balance >= :amount "
"AND #s = :active "
"AND version = :from_v"
),
"ExpressionAttributeNames": {"#s": "status"},
"ExpressionAttributeValues": {
":amount": {"N": str(amount)},
":active": {"S": "ACTIVE"},
":from_v": {"N": str(from_version)},
},
}
},
# 2. Update: débito na conta origem
{
"Update": {
"TableName": "accounts-table",
"Key": {
"PK": {"S": f"ACCOUNT#{from_account_id}"},
"SK": {"S": "METADATA"},
},
"UpdateExpression": (
"SET balance = balance - :amount, "
"version = version + :inc, "
"updatedAt = :ts"
),
"ExpressionAttributeValues": {
":amount": {"N": str(amount)},
":inc": {"N": "1"},
":ts": {"S": ts},
},
}
},
# 3. Update: crédito na conta destino (com versioning)
{
"Update": {
"TableName": "accounts-table",
"Key": {
"PK": {"S": f"ACCOUNT#{to_account_id}"},
"SK": {"S": "METADATA"},
},
"UpdateExpression": (
"SET balance = balance + :amount, "
"version = version + :inc, "
"updatedAt = :ts"
),
"ConditionExpression": "version = :to_v",
"ExpressionAttributeValues": {
":amount": {"N": str(amount)},
":inc": {"N": "1"},
":to_v": {"N": str(to_version)},
":ts": {"S": ts},
},
}
},
# 4. Put: registro imutável de auditoria
{
"Put": {
"TableName": "accounts-table",
"Key": {
"PK": {"S": f"TRANSFER#{transfer_id}"},
"SK": {"S": "RECORD"},
},
"Item": {
"PK": {"S": f"TRANSFER#{transfer_id}"},
"SK": {"S": "RECORD"},
"fromAccount": {"S": from_account_id},
"toAccount": {"S": to_account_id},
"amount": {"N": str(amount)},
"createdAt": {"S": ts},
"status": {"S": "COMPLETED"},
},
# Garante que transfer_id é único (UUID collision guard)
"ConditionExpression": "attribute_not_exists(PK)",
}
},
]
}
if idempotency_key:
kwargs["ClientRequestToken"] = idempotency_key
# Nota: TransactWriteItems via baixo nível (client) não via resource
client = boto3.client("dynamodb", region_name="us-east-1")
client.transact_write_items(**kwargs)
return {
"success": True,
"transferId": transfer_id,
"amount": str(amount),
}
except ClientError as e:
code = e.response["Error"]["Code"]
if code == "TransactionCanceledException":
# Extrai os motivos de cancelamento por item
reasons = e.response.get("CancellationReasons", [])
failed = [
{"index": i, "code": r.get("Code"), "message": r.get("Message")}
for i, r in enumerate(reasons)
if r.get("Code") != "None"
]
raise ValueError(
f"Transaction cancelled. Reasons: {failed}"
) from e
if code == "TransactionInProgressException":
# Conflito com outra transação em andamento no mesmo item
# SDK retenta automaticamente, mas se chegou aqui, esgotou retries
raise RuntimeError("Transaction conflict — retry after backoff") from e
raise
# ── Leitura consistente pós-transação ─────────────────────────────────
def get_account_balance(account_id: str) -> dict:
"""
TransactGetItems: lê saldo de forma atomicamente consistente.
Garante que não vemos estado parcial de uma transação em andamento.
"""
client = boto3.client("dynamodb", region_name="us-east-1")
response = client.transact_get_items(
TransactItems=[
{
"Get": {
"TableName": "accounts-table",
"Key": {
"PK": {"S": f"ACCOUNT#{account_id}"},
"SK": {"S": "METADATA"},
},
"ProjectionExpression": "balance, version, #s",
"ExpressionAttributeNames": {"#s": "status"},
}
}
]
)
item = response["Responses"][0].get("Item")
if not item:
raise ValueError(f"Account {account_id} not found")
return {
"balance": item["balance"]["N"],
"version": int(item["version"]["N"]),
"status": item["status"]["S"],
}
CLI — Examples of conditional operations and transactions
# 1. PutItem com attribute_not_exists (put-if-not-exists)
aws dynamodb put-item \
--table-name accounts-table \
--item '{
"PK": {"S": "ACCOUNT#acc-001"},
"SK": {"S": "METADATA"},
"balance": {"N": "1000"},
"version": {"N": "0"},
"status": {"S": "ACTIVE"}
}' \
--condition-expression "attribute_not_exists(PK)"
# 2. UpdateItem com optimistic locking (version counter)
aws dynamodb update-item \
--table-name accounts-table \
--key '{"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}}' \
--update-expression "SET balance = balance - :amount, version = version + :inc" \
--condition-expression "version = :expected AND balance >= :amount AND #s = :active" \
--expression-attribute-names '{"#s": "status"}' \
--expression-attribute-values '{
":amount": {"N": "200"},
":inc": {"N": "1"},
":expected": {"N": "0"},
":active": {"S": "ACTIVE"}
}' \
--return-values ALL_NEW
# 3. TransactWriteItems via CLI (transferência simplificada)
aws dynamodb transact-write-items \
--transact-items '[
{
"Update": {
"TableName": "accounts-table",
"Key": {"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}},
"UpdateExpression": "SET balance = balance - :amt, version = version + :inc",
"ConditionExpression": "balance >= :amt AND version = :v",
"ExpressionAttributeValues": {
":amt": {"N": "100"},
":inc": {"N": "1"},
":v": {"N": "1"}
}
}
},
{
"Update": {
"TableName": "accounts-table",
"Key": {"PK": {"S": "ACCOUNT#acc-002"}, "SK": {"S": "METADATA"}},
"UpdateExpression": "SET balance = balance + :amt, version = version + :inc",
"ExpressionAttributeValues": {
":amt": {"N": "100"},
":inc": {"N": "1"}
}
}
}
]' \
--client-request-token "transfer-$(uuidgen)" \
--return-consumed-capacity TOTAL
# 4. TransactGetItems — snapshot atômico de múltiplas contas
aws dynamodb transact-get-items \
--transact-items '[
{
"Get": {
"TableName": "accounts-table",
"Key": {"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}},
"ProjectionExpression": "balance, version"
}
},
{
"Get": {
"TableName": "accounts-table",
"Key": {"PK": {"S": "ACCOUNT#acc-002"}, "SK": {"S": "METADATA"}},
"ProjectionExpression": "balance, version"
}
}
]'
# 5. Verificar custo de transações no CloudWatch
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB \
--metric-name ConsumedWriteCapacityUnits \
--dimensions Name=TableName,Value=accounts-table \
--start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ')" \
--end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--period 300 \
--statistics Sum
# 6. Verificar conflitos de transação
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB \
--metric-name TransactionConflict \
--dimensions Name=TableName,Value=accounts-table \
--start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ')" \
--end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--period 300 \
--statistics Sum
Common pitfalls
1. Same item twice in the same transaction → validation error
[FACT] TransactWriteItems does not allow two actions on the same item within the same call — neither ConditionCheck + Update, nor two Updates. If you need to check and update the same item, combine the condition directly in the Update with ConditionExpression.
2. Transactions in Global Tables: ACID only in the write region
[FACT] TransactWriteItems guarantees atomicity only in the region where it was invoked. Replication to other regions occurs asynchronously and may show partial state transitionally. Do not assume cross-region consistency with transactions.
3. TransactionCanceledException: extract per-item reasons
[FACT] When a transaction is cancelled, the exception contains CancellationReasons — an array with one entry per item, in the same order as TransactItems. Only the items that failed have a Code different from "None". Without extracting these reasons, it is impossible to know which condition failed.
4. BatchWriteItem is not transactional — common confusion
[FACT] BatchWriteItem is a convenience operation (reduces round-trips), not transactional. Some items may be written successfully while others fail. For all-or-nothing atomicity, use TransactWriteItems. The cost difference: BatchWriteItem = 1× WCU/item; TransactWriteItems = 2× WCU/item.
5. Reserved words in ConditionExpression
[FACT] status, name, size, value, type, timestamp, among others, are reserved words in DynamoDB. Using them directly in expressions causes a syntax error. Always use ExpressionAttributeNames with the # prefix to substitute: "#s": "status".
6. Doubled cost when using transactions with DAX
[FACT] TransactWriteItems via DAX: beyond the normal 2× WCUs, DAX internally executes a TransactGetItems to populate the cache after each transactional write — adding 2× RCUs per item. A 500-byte item in TransactWriteItems via DAX consumes 2 WCUs + 2 RCUs.
Reflection exercise
You are modeling a ticket reservation system. When a customer purchases a ticket, you need to:
1. Verify that the event still has available capacity (availableSeats > 0)
2. Decrement availableSeats on the event item
3. Create a ticket item for the customer
4. Record the sale in an audit item
Answer:
-
What is the problem with implementing this flow with 3 separate sequential
PutItem/UpdateItemcalls? Describe a concrete race condition scenario that can occur. -
You decide to use
TransactWriteItems. Which 3 or 4 actions do you place in the transaction and whatConditionExpressiondo you use to guarantee the operation is safe? Write the skeleton of the call. -
The system has 100 req/s of simultaneous purchases for the same event. How many
TransactionConflicts would you expect? What can be done to reduce conflicts while maintaining consistency?
Resources for further study
- [FACT] DynamoDB Transactions — How it works: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html
- [FACT] ConditionExpression — CLI examples: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
- [FACT] Optimistic locking with version number: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/BestPractices_OptimisticLocking.html
- [FACT] Best practices — implementing version control: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/BestPractices_ImplementingVersionControl.html