DynamoDB transactions are the tool for situations where several items have to be right together: a bike counts as rented, the customer has one more active rental on their account, and the rental exists as a record of its own. If one of these three steps is missing, you end up with a bike nobody can rent anymore, or a customer who suddenly rides three bikes at once. With TransactWriteItems, either everything happens or nothing does.

I care about this topic because teams often reach for DynamoDB transactions too late or too often. Too late, because many only notice after the first inconsistent record that three separate writes in a Lambda function are not a unit. Too often, because transactions consume twice the capacity and tend to collide with each other under concurrent load. In this article you build a small rental API step by step with Lambda, the Serverless Framework and TypeScript, and you learn how to evaluate the error details of a canceled transaction properly.

In short

With TransactWriteItems you write up to 100 items atomically, all or nothing, and with ConditionCheck you can also verify items you do not change. The key insight: when DynamoDB cancels a transaction, the CancellationReasons list tells you position by position which condition failed. The biggest pitfall is that the SDK does not retry canceled transactions on its own, and every action costs double capacity, even if the transaction fails.

What you will learn in this article

  • When you need DynamoDB transactions and when a simple condition is enough
  • Writing a rental with four actions atomically in one Lambda function
  • Turning CancellationReasons into domain errors and fitting HTTP status codes
  • How to retry conflicts on purpose and answer duplicate requests without double bookings
  • Judging cost, limits and IAM permissions for transactions

What are DynamoDB transactions?

DynamoDB offers two transaction APIs. TransactWriteItems groups up to 100 write actions on up to 100 distinct items, spread across one or more tables in the same AWS account and Region. TransactGetItems reads up to 100 items as a consistent snapshot. Together, the items in one transaction may not exceed 4 MB.

Atomic means that either all actions are applied, or DynamoDB discards the whole request. That makes transactions clearly different from BatchWriteItem. A batch is just a bulk order where some writes may go through while others come back as “unprocessed”. For bulk imports the batch is therefore the better choice; for business units of work, use the transaction.

The four actions in TransactWriteItems

A write transaction gives you four actions: Put creates or replaces an item, Update changes individual attributes, Delete removes an item, and ConditionCheck only evaluates a condition without writing anything. Each action can carry its own ConditionExpression, a condition that must hold at write time. If a single condition is false, the whole transaction is canceled.

One rule surprises almost everybody the first time: each item may appear only once per transaction. So you cannot check an item with ConditionCheck first and then change it with Update. Instead, the check belongs directly in the update’s condition. Also, transactions only work on tables, not on indexes.

Isolation and what other readers see

Between a transaction and single operations such as GetItem, PutItem or UpdateItem, DynamoDB guarantees serializable isolation. A single GetItem therefore sees an item either before or after the transaction, never half written. Query and Scan, on the other hand, only read “read committed”: if you query several items while a transaction is running, some of them may already show the new state. When you need a coherent view of several items, use TransactGetItems.

Two more points belong in every architecture decision. First, the changes of a transaction reach global secondary indexes and DynamoDB Streams gradually. A stream consumer may therefore see the three records of a rental in different batches, mixed with other transactions. Second, with global tables the ACID guarantees only apply in the Region you write to. Other Regions can briefly show a partially replicated transaction.

When do you need DynamoDB transactions?

The rule of thumb I work with: if a business rule only concerns one item, a conditional write is enough. Only when a rule has to hold across several items is a transaction worth it. Lowering the stock of a product by one, as long as it is greater than zero, takes a single UpdateItem with a condition. I showed how that looks with a version number against lost updates in the article on DynamoDB optimistic locking.

Typical cases for a transaction, on the other hand:

  • A state changes on several items at once, for example bike “rented” and rental “open”.
  • A counter on one item limits what may happen on another item, such as at most two open rentals per customer.
  • Unique values like email addresses, which you secure with an additional placeholder item and attribute_not_exists.
  • An item may only be written if another item is in a certain state, without changing that other item.

Transactions are not a good idea for bulk imports, for workflows spanning several services, or for data that may be updated later through events anyway. If a payment, an email or a third-party system is involved besides DynamoDB, a database transaction will not help you. That is what the saga pattern with Step Functions or the outbox pattern are for.

The example: a rental at a bike-sharing service

Our fictional bike rental has stations, bikes, customers and rentals. Everything lives in a single table with the partition key pk; the prefixes STATION#, BIKE#, CUSTOMER# and RENTAL# separate the item types. When someone starts a rental, four rules must hold at the same time:

  1. The station is open. It is only checked, not changed, so this is a ConditionCheck.
  2. The customer is active and has fewer than two open rentals. The counter goes up by one.
  3. The bike is available and parked at exactly this station. It switches to “rented”.
  4. The rental does not exist yet. It is created with Put.

Diagram: Lambda sends TransactWriteItems with four actions, result is all written or nothing plus CancellationReasons

Four actions, two possible outcomes: everything written or nothing.

The order of the actions is no accident. When DynamoDB cancels the transaction, it returns a list of cancellation reasons in exactly this order. Position 0 belongs to the station, position 1 to the customer, and so on. The entire error handling builds on that.

🎬 Animation: transaction with four actions – this animated diagram only runs on my site: watch it on sascha-ms.de

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account with permissions for Lambda, API Gateway, DynamoDB, IAM and CloudFormation
  • The Serverless Framework v4 (npm i -g serverless); version 4 requires signing in or a license key, and it is free to use for small organizations
  • The AWS CLI for test data and curl to try things out
  • Basic DynamoDB knowledge: partition key, UpdateItem, conditions

Step by step: DynamoDB transactions with Lambda

Step 1: Create the project

Create an empty folder and install SDK v3 plus the tooling for TypeScript and tests. The package @aws-sdk/lib-dynamodb contains the document client, which lets you write plain JavaScript values instead of DynamoDB’s type notation ({ "S": "…" }).

mkdir bike-rental-transactions && cd bike-rental-transactions npm init -y npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm i -D typescript vitest @types/node @types/aws-lambda mkdir src test 

The tsconfig.json is deliberately short. Serverless Framework v4 bundles TypeScript with esbuild during deployment, so the compiler is only used for type checking here.

// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } 

Step 2: Build the transaction for starting a rental

All functions from steps 2 to 6 live in the file src/rental-service.ts. I separate building the transaction from running it. The function buildStartRentalTransaction is pure: it takes the table name and the input and returns the finished command object. That makes it easy to test, and you see all four actions in one place.

Two details deserve a second look. On the customer I set ReturnValuesOnConditionCheckFailure: "ALL_OLD". If the condition fails, DynamoDB then includes the current customer item in the cancellation reason. Later you can tell from it whether the customer is blocked or has only reached their limit. And the rental gets attribute_not_exists(pk): the same rental can never be created twice.

import { TransactionCanceledException } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, TransactWriteCommand, type TransactWriteCommandInput, } from "@aws-sdk/lib-dynamodb"; export const MAX_ACTIVE_RENTALS = 2; export interface StartRentalInput { rentalId: string; // comes from the Idempotency-Key header (UUID, max. 36 characters) customerId: string; bikeId: string; stationId: string; startedAt: string; } export interface ReturnRentalInput { rentalId: string; returnedAt: string; } export type RentalError = | "STATION_CLOSED" | "CUSTOMER_BLOCKED" | "RENTAL_LIMIT_REACHED" | "BIKE_NOT_AVAILABLE" | "RENTAL_ALREADY_EXISTS" | "RENTAL_NOT_OPEN" | "CONFLICT" | "UNKNOWN"; export class RentalRejectedError extends Error { constructor(public readonly reason: RentalError) { super(reason); this.name = "RentalRejectedError"; } } export const keys = { station: (id: string) => ({ pk: `STATION#${id}` }), customer: (id: string) => ({ pk: `CUSTOMER#${id}` }), bike: (id: string) => ({ pk: `BIKE#${id}` }), rental: (id: string) => ({ pk: `RENTAL#${id}` }), }; // Order matters: CancellationReasons arrive in exactly this order. export function buildStartRentalTransaction( tableName: string, input: StartRentalInput, ): TransactWriteCommandInput { return { // No own ClientRequestToken: the SDK sets one per call, and startedAt changes on every // client retry (same token + different parameters = IdempotentParameterMismatch). TransactItems: [ { ConditionCheck: { TableName: tableName, Key: keys.station(input.stationId), ConditionExpression: "isOpen = :true", ExpressionAttributeValues: { ":true": true }, }, }, { Update: { TableName: tableName, Key: keys.customer(input.customerId), UpdateExpression: "SET activeRentals = activeRentals + :one", ConditionExpression: "accountStatus = :active AND activeRentals < :max", ExpressionAttributeValues: { ":one": 1, ":active": "active", ":max": MAX_ACTIVE_RENTALS }, ReturnValuesOnConditionCheckFailure: "ALL_OLD", }, }, { Update: { TableName: tableName, Key: keys.bike(input.bikeId), UpdateExpression: "SET bikeStatus = :rented, rentalId = :rentalId", ConditionExpression: "bikeStatus = :available AND stationId = :stationId", ExpressionAttributeValues: { ":rented": "rented", ":available": "available", ":rentalId": input.rentalId, ":stationId": input.stationId, }, }, }, { Put: { TableName: tableName, Item: { ...keys.rental(input.rentalId), rentalStatus: "open", customerId: input.customerId, bikeId: input.bikeId, stationId: input.stationId, startedAt: input.startedAt, }, ConditionExpression: "attribute_not_exists(pk)", }, }, ], }; } 

Step 3: The return as a second transaction

The return is the mirror image: rental to “returned”, bike available again, the customer’s counter minus one. The condition rentalStatus = :open prevents a return request sent twice from lowering the counter twice. On the bike I additionally check that it really still belongs to this rental.

export function buildReturnRentalTransaction( tableName: string, rental: { rentalId: string; customerId: string; bikeId: string }, returnedAt: string, ): TransactWriteCommandInput { return { TransactItems: [ { Update: { TableName: tableName, Key: keys.rental(rental.rentalId), UpdateExpression: "SET rentalStatus = :returned, returnedAt = :at", ConditionExpression: "rentalStatus = :open", ExpressionAttributeValues: { ":returned": "returned", ":open": "open", ":at": returnedAt }, }, }, { Update: { TableName: tableName, Key: keys.bike(rental.bikeId), UpdateExpression: "SET bikeStatus = :available REMOVE rentalId", ConditionExpression: "rentalId = :rentalId", ExpressionAttributeValues: { ":available": "available", ":rentalId": rental.rentalId }, }, }, { Update: { TableName: tableName, Key: keys.customer(rental.customerId), UpdateExpression: "SET activeRentals = activeRentals - :one", ConditionExpression: "activeRentals > :zero", ExpressionAttributeValues: { ":one": 1, ":zero": 0 }, }, }, ], }; } 

Evaluating CancellationReasons in DynamoDB transactions

When a transaction fails, the SDK throws a TransactionCanceledException. Many examples online stop right there and return a blanket “conflict”. That throws away the most useful information DynamoDB gives you: the exception contains the field CancellationReasons, a list with one entry per action, in the same order as your request.

Every entry has a Code. Actions without an error get the text "None" (really as a string, not null). The most important other codes:

Code Meaning Retry?
ConditionalCheckFailed The condition of this action was false. No, business error
TransactionConflict Another transaction is working on the same item right now. Yes, with backoff
ThrottlingError An on-demand table or index is scaling up. Yes, with backoff
ProvisionedThroughputExceeded Provisioned capacity is not enough. Yes, but check capacity
ValidationError Invalid request, for example an item larger than 400 KB. No, code bug
ItemCollectionSizeLimitExceeded An item collection with a local index is too large. No, check the data model

Step 4: Translate cancellation reasons into domain errors

The function explainStartCancellation destructures the list into the four positions. A conflict takes precedence, because the other codes are not meaningful in that case. After that I check the rental first, since “already exists” points to a repeated request. For the customer, the returned item decides the error message.

interface Reason { Code?: string; Item?: Record<string, unknown>; } // Maps the position-based CancellationReasons to a domain error. export function explainStartCancellation(reasons: Reason[] = []): RentalError { if (reasons.some((r) => r.Code === "TransactionConflict")) return "CONFLICT"; const [station, customer, bike, rental] = reasons; if (rental?.Code === "ConditionalCheckFailed") return "RENTAL_ALREADY_EXISTS"; if (station?.Code === "ConditionalCheckFailed") return "STATION_CLOSED"; if (customer?.Code === "ConditionalCheckFailed") { return customer.Item?.accountStatus === "active" ? "RENTAL_LIMIT_REACHED" : "CUSTOMER_BLOCKED"; } if (bike?.Code === "ConditionalCheckFailed") return "BIKE_NOT_AVAILABLE"; return "UNKNOWN"; } export function explainReturnCancellation(reasons: Reason[] = []): RentalError { if (reasons.some((r) => r.Code === "TransactionConflict")) return "CONFLICT"; if (reasons[0]?.Code === "ConditionalCheckFailed") return "RENTAL_NOT_OPEN"; return "UNKNOWN"; } 

Step 5: Retry conflicts on purpose

This is the pitfall that causes the most trouble in practice: the AWS SDKs do not automatically retry a TransactionCanceledException. That is correct, too, because a ConditionalCheckFailed does not improve with retries. A TransactionConflict, however, does, because the other transaction finishes after a few milliseconds. So I only retry conflicts, at most three times and with a random wait (full jitter), so that two Lambda instances do not get in each other’s way again in lockstep.

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // The SDK does not retry TransactionCanceledException – we only retry real conflicts. export async function sendWithConflictRetry( client: Pick<DynamoDBDocumentClient, "send">, input: TransactWriteCommandInput, explain: (reasons?: Reason[]) => RentalError, maxAttempts = 3, ): Promise<void> { for (let attempt = 1; ; attempt++) { try { await client.send(new TransactWriteCommand(input)); return; } catch (error) { if (!(error instanceof TransactionCanceledException)) throw error; const reason = explain(error.CancellationReasons as Reason[] | undefined); if (reason !== "CONFLICT" || attempt >= maxAttempts) throw new RentalRejectedError(reason); await sleep(Math.random() * 50 * 2 ** attempt); // full jitter } } } 

Step 6: Duplicate requests without double bookings

Mobile networks are unreliable, and apps like to send requests a second time. That is why the client sends an Idempotency-Key header with a UUID, which I use directly as the rental ID. If the same request arrives again, the Put action fails on attribute_not_exists(pk). The service then reads the existing rental and answers with 200 instead of 201, as long as customer and bike match.

export class RentalService { constructor( private readonly client: Pick<DynamoDBDocumentClient, "send">, private readonly tableName: string, ) {} async startRental(input: StartRentalInput): Promise<{ replayed: boolean }> { try { await sendWithConflictRetry( this.client, buildStartRentalTransaction(this.tableName, input), explainStartCancellation, ); return { replayed: false }; } catch (error) { // Same key again: the rental already exists. if (error instanceof RentalRejectedError && error.reason === "RENTAL_ALREADY_EXISTS") { const existing = await this.getRental(input.rentalId); if (existing?.customerId === input.customerId && existing.bikeId === input.bikeId) { return { replayed: true }; } } throw error; } } async returnRental(input: ReturnRentalInput): Promise<void> { const rental = await this.getRental(input.rentalId); if (!rental) throw new RentalRejectedError("RENTAL_NOT_OPEN"); await sendWithConflictRetry( this.client, buildReturnRentalTransaction( this.tableName, { rentalId: input.rentalId, customerId: rental.customerId, bikeId: rental.bikeId }, input.returnedAt, ), explainReturnCancellatio...
Source:https://dev.to/sascha-ms/dynamodb-transactions-transactwriteitems-with-lambda-5f6h