DynamoDB Transactions With AWS SDK v3 (TransactWriteItems)

A heavy round steel vault door standing open with its locking bolts visible

Photo by David Trinks on Unsplash

To use DynamoDB TransactWriteItems with AWS SDK v3, send a TransactWriteCommand from @aws-sdk/lib-dynamodb with up to 100 Put, Update, Delete and ConditionCheck actions. Either all of them succeed or none do. If one condition fails, the SDK throws TransactionCanceledException, whose CancellationReasons list one code per action, in request order.

Some writes only make sense together: take stock and create the order, move credit from one account to another, claim a username and create the profile. A single UpdateCommand is atomic for one item. For several items, DynamoDB has transactions, and the AWS SDK for JavaScript v3 exposes them through the document client.

This guide is for TypeScript and Node.js developers. You’ll write a four-action DynamoDB TransactWriteItems call with AWS SDK v3, turn a cancellation into a useful answer, make retries safe with ClientRequestToken, read items back consistently with TransactGetCommand, and see what transactions cost in capacity. The code was type-checked with strict tsc and tested against aws-sdk-client-mock with @aws-sdk/lib-dynamodb 3.1141.0 in September 2026. For single-item conditions and counters, start with the guide to DynamoDB UpdateItem with AWS SDK v3: conditions and counters.

When should you use a transaction instead of a batch?

BatchWriteItem is for throughput: 25 puts or deletes per call, no conditions, and each item succeeds or fails on its own. TransactWriteItems is for correctness: conditions on any item, updates as well as puts and deletes, and all-or-nothing. The guide to DynamoDB BatchWriteItem with AWS SDK v3 compares the write APIs side by side. PartiQL has its own transactional call, and the guide to DynamoDB PartiQL statements and ExecuteTransaction in AWS SDK v3 covers when to use it.

DynamoDB’s guidance is to keep transactions small and to avoid them for bulk ingestion. Use one when a partial write would leave your data wrong.

What are the DynamoDB TransactWriteItems limits?

  • Up to 100 actions on up to 100 distinct items, in one or more tables in the same account and Region.
  • 4 MB aggregate item size per transaction, and each item still has the 400 KB item limit.
  • One action per item. You can’t ConditionCheck and Update the same item in one transaction.
  • No indexes. Actions target table items by primary key; transactions can’t be performed using indexes.
  • Region-scoped on global tables. ACID guarantees apply only in the Region where you called the API; other replicas may briefly see part of it.

These come from the DynamoDB Developer Guide page on how transactions work, checked in September 2026.

Prerequisites

How to use DynamoDB TransactWriteItems with AWS SDK v3, step by step

  1. Create a document clientDynamoDBDocumentClient.from(new DynamoDBClient({})) lets you pass plain JavaScript values instead of { S: "..." } attribute values.
  2. List the actions in a fixed orderEach entry in TransactItems holds exactly one of ConditionCheck, Put, Update or Delete. Keep the order stable: cancellation reasons come back in the same order.
  3. Put the business rules in conditionsstock >= :q, attribute_not_exists(pk), #s = :active. A failed condition cancels everything.
  4. Ask for the item on failureReturnValuesOnConditionCheckFailure: "ALL_OLD" puts the current item into the cancellation reason, so you can report “only 1 left”.
  5. Catch TransactionCanceledExceptionMap CancellationReasons to your own result type; rethrow codes you don’t expect.
  6. Decide on a tokenPass a ClientRequestToken when your code may resend the same request.

Example: placing an order in one transaction

orders-transactions.ts

// orders-transactions.ts: DynamoDB TransactWriteItems and TransactGetItems with AWS SDK for JavaScript v3
import { randomUUID } from "node:crypto";
import { DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, TransactGetCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
  marshallOptions: { removeUndefinedValues: true },
});
const TABLE = process.env.TABLE_NAME ?? "shop";

export interface OrderInput {
  orderId: string; // a UUID, so it also fits ClientRequestToken (1 to 36 characters)
  customerId: string;
  sku: string;
  quantity: number;
  createdAt: string; // set once by the caller: a resend must be byte-for-byte the same request
}

export type PlaceOrderResult =
  | { ok: true }
  | { ok: false; reason: "CUSTOMER_INACTIVE" | "OUT_OF_STOCK" | "DUPLICATE_ORDER" | "CONFLICT"; detail?: Record<string, unknown> };

// One label per action, in TransactItems order: CancellationReasons come back in the same order.
const ACTIONS = ["customer check", "stock update", "order put", "counter update"] as const;

/** Check the customer, take stock, create the order and bump a counter: all four succeed or none do. */
async function writeOrder(o: OrderInput, token: string): Promise<void> {
  await ddb.send(new TransactWriteCommand({
    ClientRequestToken: token, // an identical resend with this token within 10 minutes isn't applied twice
    TransactItems: [
      {
        ConditionCheck: {
          TableName: TABLE,
          Key: { pk: `CUSTOMER#${o.customerId}`, sk: "PROFILE" },
          ConditionExpression: "#s = :active",
          ExpressionAttributeNames: { "#s": "status" },
          ExpressionAttributeValues: { ":active": "ACTIVE" },
        },
      },
      {
        Update: {
          TableName: TABLE,
          Key: { pk: `PRODUCT#${o.sku}`, sk: "STOCK" },
          UpdateExpression: "SET stock = stock - :q",
          ConditionExpression: "stock >= :q",
          ExpressionAttributeValues: { ":q": o.quantity },
          ReturnValuesOnConditionCheckFailure: "ALL_OLD", // put the current stock in the cancellation reason
        },
      },
      {
        Put: {
          TableName: TABLE,
          Item: { pk: `ORDER#${o.orderId}`, sk: "ORDER", customerId: o.customerId, sku: o.sku, quantity: o.quantity, createdAt: o.createdAt },
          ConditionExpression: "attribute_not_exists(pk)",
        },
      },
      {
        Update: {
          TableName: TABLE,
          Key: { pk: `CUSTOMER#${o.customerId}`, sk: "STATS" },
          UpdateExpression: "ADD orderCount :one",
          ExpressionAttributeValues: { ":one": 1 },
        },
      },
    ],
  }));
}

/** Turn CancellationReasons into a decision. Codes other than ConditionalCheckFailed and TransactionConflict are rethrown. */
function explain(err: TransactionCanceledException): PlaceOrderResult {
  const reasons = err.CancellationReasons ?? [];
  reasons.forEach((r, i) => {
    if (r.Code && r.Code !== "None") console.warn(`${ACTIONS[i] ?? `action ${i}`}: ${r.Code}${r.Message ? ` (${r.Message})` : ""}`);
  });
  if (reasons.some((r) => r.Code === "TransactionConflict")) return { ok: false, reason: "CONFLICT" };
  const failed = (i: number) => reasons[i]?.Code === "ConditionalCheckFailed";
  if (failed(0)) return { ok: false, reason: "CUSTOMER_INACTIVE" };
  if (failed(1)) {
    const item = reasons[1]?.Item; // raw AttributeValues: the document client doesn't unmarshall exceptions
    return { ok: false, reason: "OUT_OF_STOCK", detail: item ? unmarshall(item) : undefined };
  }
  if (failed(2)) return { ok: false, reason: "DUPLICATE_ORDER" };
  throw err;
}

/**
 * Place an order. The first attempt uses the order ID as its token, so resending the same order
 * (after a timeout, or a redelivered queue message) is safe. Conflict retries get a fresh token;
 * attribute_not_exists on the order item still stops a second copy.
 */
export async function placeOrder(o: OrderInput, maxAttempts = 3): Promise<PlaceOrderResult> {
  for (let attempt = 1; ; attempt++) {
    try {
      await writeOrder(o, attempt === 1 ? o.orderId : randomUUID());
      return { ok: true };
    } catch (err) {
      if (!(err instanceof TransactionCanceledException)) throw err;
      const result = explain(err);
      if (result.ok || result.reason !== "CONFLICT" || attempt >= maxAttempts) return result;
      const delay = Math.random() * 100 * 2 ** attempt; // full jitter: 0-200 ms, 0-400 ms, ...
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

/** Read the order and the stock it changed as one consistent snapshot. */
export async function readOrderAndStock(orderId: string, sku: string): Promise<{ order?: Record<string, unknown>; stock?: number }> {
  const res = await ddb.send(new TransactGetCommand({
    TransactItems: [
      { Get: { TableName: TABLE, Key: { pk: `ORDER#${orderId}`, sk: "ORDER" } } },
      { Get: { TableName: TABLE, Key: { pk: `PRODUCT#${sku}`, sk: "STOCK" }, ProjectionExpression: "stock" } },
    ],
  }));
  const [order, stock] = res.Responses ?? [];
  return { order: order?.Item, stock: typeof stock?.Item?.stock === "number" ? stock.Item.stock : undefined };
}

async function main(): Promise<void> {
  const order: OrderInput = {
    orderId: randomUUID(), customerId: "c-1042", sku: "SKU-RED-MUG", quantity: 2, createdAt: new Date().toISOString(),
  };
  const result = await placeOrder(order);
  console.log(order.orderId, result);
  if (result.ok) console.log(await readOrderAndStock(order.orderId, order.sku));
}

if (process.argv[1]?.endsWith("orders-transactions.ts")) {
  main().catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Run it with TABLE_NAME=shop npx tsx orders-transactions.ts. The four actions check that the customer is active, take the stock, create the order only if it doesn’t exist, and count the customer’s orders. One detail catches people out: the document client converts inputs and outputs, but not exceptions. The Item in a cancellation reason arrives as raw attribute values, so the example converts it with unmarshall.

How do you read CancellationReasons?

A canceled transaction has one reason per action. Actions that were fine have the code "None" as a literal string. The TransactWriteItems API reference lists these codes:

Code What it means What to do
None This action was fine Look at the other reasons
ConditionalCheckFailed The action’s condition was false A business outcome: return it to the caller, don’t retry
TransactionConflict Another transaction was changing the item Retry with backoff
ProvisionedThroughputExceeded / ThrottlingError Not enough capacity on a table or index Back off; review capacity or on-demand scaling
ItemCollectionSizeLimitExceeded An item collection hit its size limit A data model issue; alert
ValidationError A bad parameter, type mismatch or oversized item A bug: fix the request

The DynamoDB docs say the AWS SDKs don’t retry a TransactionCanceledException, so with DynamoDB TransactWriteItems in AWS SDK v3, conflict retries are yours to write. The example retries only TransactionConflict, up to three attempts with full-jitter backoff. The guide to configure retry and timeout settings in AWS SDK for JavaScript v3 covers the SDK-level retries that still apply to throttling and network errors.

How does ClientRequestToken make a transaction idempotent?

ClientRequestToken is 1 to 36 characters. If a TransactWriteItems call with a token succeeds, later calls with the same token and the same parameters return success without changing anything again. The token is valid for 10 minutes after the request that used it finishes. Reuse it with different parameters inside that window and DynamoDB returns IdempotentParameterMismatch.

In the v3 SDK, the token is an idempotency-token member of the input: if you leave it out, the client fills in a random one when it serializes the request. The SDK’s own retries resend that serialized request, so they reuse it. Your code’s retries create a new command, so they get a new token unless you pass one.

The example passes the order ID on the first attempt. If a caller times out and sends the same order again, with the same createdAt, DynamoDB recognizes it. The docs describe the token behavior for a call that succeeded, but don’t say whether a canceled call’s token can be reused, so conflict retries use a fresh token. The attribute_not_exists(pk) condition on the order item still stops a second order.

What does a DynamoDB transaction cost?

There’s no extra fee for transactions, but DynamoDB performs two underlying writes for every item: one to prepare and one to commit. Canceled transactions still consume that capacity for the items they attempted.

Worked example, on-demand in us-east-1 at $0.625 per million write request units (AWS Price List, September 2026): the order transaction writes three items under 1 KB (stock, order, counter). That’s 3 × 2 = 6 write request units per order, or 6 million per million orders: 6 × $0.625 = $3.75. The same three writes without a transaction would be 3 million units, $1.875. The ConditionCheck on the customer consumes capacity on top of that. On provisioned tables the same doubling applies to write capacity units. The script to find overprovisioned DynamoDB read and write capacity shows whether a table has room for it.

Measure it: the Developer Guide describes two underlying operations for every item in a transaction. For exact numbers on your items, set ReturnConsumedCapacity: "TOTAL" on a test call and read ConsumedCapacity from the response.

Reading a consistent snapshot with TransactGetCommand

readOrderAndStock in the example uses TransactGetCommand: up to 100 Get actions, 4 MB, all read as one snapshot. Two separate GetCommand calls running during a write can see the new order and the old stock. A transactional read uses two read capacity units per item up to 4 KB, one to prepare and one to commit. For listing many orders, a normal Query is right; the guide to query DynamoDB with AWS SDK v3 covers keys, indexes and pagination.

Testing transactions without a table

Cancellations are hard to trigger on demand against a real table, so test the decoding with a mocked client. This test runs with Node’s built-in test runner:

orders-transactions.test.ts

// orders-transactions.test.ts: run with `npx tsx --test orders-transactions.test.ts`
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { mockClient } from "aws-sdk-client-mock";
import { TransactionCanceledException } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
import { placeOrder } from "./orders-transactions.js";

const ddbMock = mockClient(DynamoDBDocumentClient);
const order = {
  orderId: "5f0c9a2e-8a57-4f4e-9d1b-2b6f0f0d7c11", customerId: "c-1042", sku: "SKU-RED-MUG", quantity: 2,
  createdAt: "2027-06-10T09:00:00.000Z",
};
const canceled = (codes: string[]) => new TransactionCanceledException({
  message: "Transaction cancelled",
  $metadata: {},
  CancellationReasons: codes.map((Code) => ({ Code })),
});

beforeEach(() => ddbMock.reset());

test("reports out of stock from the second cancellation reason", async () => {
  ddbMock.on(TransactWriteCommand).rejects(canceled(["None", "ConditionalCheckFailed", "None", "None"]));
  const result = await placeOrder(order);
  assert.deepEqual(result, { ok: false, reason: "OUT_OF_STOCK", detail: undefined });
});

test("retries a transaction conflict, then succeeds", async () => {
  ddbMock.on(TransactWriteCommand)
    .rejectsOnce(canceled(["None", "TransactionConflict", "None", "None"]))
    .resolves({});
  assert.deepEqual(await placeOrder(order), { ok: true });
  const calls = ddbMock.commandCalls(TransactWriteCommand);
  assert.equal(calls.length, 2);
  assert.equal(calls[0]?.args[0].input.ClientRequestToken, order.orderId);
});

The guide to mock AWS SDK v3 clients in unit tests with Jest and Vitest shows the same pattern in those frameworks.

Permissions needed

There’s no separate IAM action for transactions. Each action is authorized as its single-item operation: Put as dynamodb:PutItem, Update as dynamodb:UpdateItem, Delete as dynamodb:DeleteItem, Get as dynamodb:GetItem, and ConditionCheck as dynamodb:ConditionCheckItem.

orders-transactions-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OrderTransactions",
      "Effect": "Allow",
      "Action": [
        "dynamodb:ConditionCheckItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:GetItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/shop"
    }
  ]
}

To allow these actions only inside transactions, add a condition on dynamodb:EnclosingOperation with the values TransactWriteItems and TransactGetItems, as in the Developer Guide’s IAM examples for transactions. The IAM policy generator for TypeScript code reads a module like the example and lists the actions, and the guide to find the IAM actions your AWS SDK for JavaScript code needs explains the mapping.

Troubleshooting and common mistakes

  • Canceled, but no condition failed. The API reference lists other causes: two actions on the same item, a table in another account or Region, an item that would grow past 400 KB, or throttling. Log each reason’s code and message, as the example does, to see which one.
  • IdempotentParameterMismatchException. The same token was sent with different parameters within 10 minutes, typically because a timestamp or generated ID is computed on each attempt. Build the request once and reuse it.
  • CancellationReasons is undefined. You caught an error from a different call, or checked err.name on a wrapped error. Use instanceof TransactionCanceledException from @aws-sdk/client-dynamodb.
  • Item in a reason looks like { stock: { N: "1" } }. The document client doesn’t unmarshall exceptions; call unmarshall from @aws-sdk/util-dynamodb.
  • Frequent TransactionConflict. Many transactions update one hot item, often a global counter. Split the counter, or move it out of the transaction and update it separately.

Limits: what transactions can’t do

Moving older code? v2’s DocumentClient.transactWrite() maps to TransactWriteCommand, and the free AWS SDK v2 to v3 converter drafts the change for you to review. ChatWithCloud itself generates SDK v2 code to answer questions about your account, so use it to look at tables, not to write transactions; see list AWS resources with natural language from your terminal.

Frequently asked questions

How many items can a DynamoDB transaction write?

Up to 100 actions on 100 distinct items, with a 4 MB total, in tables in the same account and Region.

How do I know which action failed in a DynamoDB transaction?

Catch TransactionCanceledException and read CancellationReasons. It has one entry per action in request order; the failed one has a code such as ConditionalCheckFailed and the others have None.

Does AWS SDK v3 retry a canceled DynamoDB transaction?

No. The SDK retries throttling and network errors, but a canceled transaction is returned to your code. Retry TransactionConflict yourself with backoff.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud