DynamoDB Update Item With AWS SDK v3: Conditions and Counters

Close-up of a mechanical counter with white digits on black rotating wheels

Photo by Annie Spratt on Unsplash

To update a DynamoDB item with AWS SDK v3, send an UpdateCommand from @aws-sdk/lib-dynamodb with the table name, the full primary key and an UpdateExpression using SET, REMOVE, ADD or DELETE. Add a ConditionExpression to update only when the item is in the state you expect, and catch ConditionalCheckFailedException when it isn’t.

This guide is for Node.js and TypeScript developers who already read from DynamoDB and now need to change items safely: rename a product without creating a ghost item, count page views under load, reserve stock without going negative, and stop two users from overwriting each other. You’ll get a typed module with each DynamoDB update item AWS SDK v3 pattern, and the rules behind them.

Reads are covered in the guide to query DynamoDB with AWS SDK v3 using keys, indexes and pagination; this one is only about writes to existing items.

Prerequisites

  • Node.js 18 or later, TypeScript and tsx, with "type": "module" in package.json.
  • @aws-sdk/client-dynamodb, @aws-sdk/lib-dynamodb and @aws-sdk/util-dynamodb.
  • A table with a composite key; the examples use pk and sk, with products stored as PRODUCT#<id> / META.
  • Credentials the SDK can resolve, as described in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.

SET, REMOVE, ADD and DELETE: what each clause does

Clause Works on What it does
SET Any type Adds or replaces attributes. Supports arithmetic (SET stock = stock - :qty), if_not_exists(path, value) and list_append.
REMOVE Any type Deletes attributes from the item.
ADD Numbers and sets only Adds to a number, starting from 0 if the attribute or item doesn’t exist, or adds members to a set.
DELETE Sets only Removes members from a set. An empty set is an error.

One expression can combine clauses, such as SET a = :a REMOVE b, but each attribute can appear only once. Two things surprise people. First, UpdateItem is an upsert: if no item has that key, it creates one with just the key and the attributes you set. Second, names like name and views are DynamoDB reserved words, so they need a placeholder in ExpressionAttributeNames.

How to update an item with DynamoDB and AWS SDK v3, step by step

  1. Wrap the client onceDynamoDBDocumentClient.from(new DynamoDBClient({})), with removeUndefinedValues: true so optional fields don’t throw.
  2. Pass the full keyPartition key and sort key if the table has one. Updates always target exactly one item.
  3. Write the expression with placeholders:values in ExpressionAttributeValues, #names for reserved words. The Document Client converts plain JavaScript values, and a Set of strings becomes a DynamoDB string set.
  4. Add a condition when state mattersattribute_exists(pk) to forbid upserts, stock >= :qty to protect invariants, version = :expected for locking.
  5. Ask for what you need backReturnValues for the updated item, ReturnValuesOnConditionCheckFailure for the item that blocked you.
  6. Handle the failure as a resultCatch ConditionalCheckFailedException and turn it into a domain answer such as “out of stock”.

Example: product updates in one module

dynamodb-updates.ts

// dynamodb-updates.ts
// UpdateItem patterns with AWS SDK for JavaScript v3 and the DynamoDB Document Client:
// SET/REMOVE, atomic counters, set ADD/DELETE, conditions, optimistic locking and a transaction.
import { ConditionalCheckFailedException, DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, TransactWriteCommand, UpdateCommand } 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 ?? "app";

export interface Product {
  pk: string; // "PRODUCT#<id>"
  sk: string; // "META"
  name: string;
  price: number;
  stock: number;
  views?: number;
  tags?: Set<string>;
  discontinuedAt?: string;
  version: number;
}

const productKey = (id: string) => ({ pk: `PRODUCT#${id}`, sk: "META" });

/** SET some attributes and REMOVE another in one call; fails instead of creating a new item. */
export async function renameProduct(id: string, name: string, price: number): Promise<Product> {
  const res = await ddb.send(new UpdateCommand({
    TableName: TABLE,
    Key: productKey(id),
    UpdateExpression: "SET #name = :name, price = :price, updatedAt = :now REMOVE discontinuedAt",
    ConditionExpression: "attribute_exists(pk)", // without this, UpdateItem upserts a new item
    ExpressionAttributeNames: { "#name": "name" }, // NAME is a DynamoDB reserved word
    ExpressionAttributeValues: { ":name": name, ":price": price, ":now": new Date().toISOString() },
    ReturnValues: "ALL_NEW",
  }));
  return res.Attributes as Product;
}

/** Atomic counter: no read first, safe under concurrency, but a retried call can count twice. */
export async function incrementViews(id: string, by = 1): Promise<number> {
  const res = await ddb.send(new UpdateCommand({
    TableName: TABLE,
    Key: productKey(id),
    UpdateExpression: "ADD #views :by",
    ExpressionAttributeNames: { "#views": "views" },
    ExpressionAttributeValues: { ":by": by },
    ReturnValues: "UPDATED_NEW",
  }));
  return Number(res.Attributes?.views ?? 0);
}

/** Add and remove members of a string set. A JavaScript Set marshals to a DynamoDB String Set. */
export async function retag(id: string, add: string[], remove: string[]): Promise<void> {
  const parts: string[] = [];
  const values: Record<string, Set<string>> = {};
  if (add.length) { parts.push("ADD tags :add"); values[":add"] = new Set(add); }
  if (remove.length) { parts.push("DELETE tags :remove"); values[":remove"] = new Set(remove); } // empty sets are invalid
  if (!parts.length) return;
  await ddb.send(new UpdateCommand({
    TableName: TABLE,
    Key: productKey(id),
    UpdateExpression: parts.join(" "),
    ExpressionAttributeValues: values,
  }));
}

/** Decrement stock only if enough is left. Returns false instead of going negative. */
export async function reserveStock(id: string, qty: number): Promise<boolean> {
  try {
    await ddb.send(new UpdateCommand({
      TableName: TABLE,
      Key: productKey(id),
      UpdateExpression: "SET stock = stock - :qty",
      ConditionExpression: "attribute_exists(pk) AND stock >= :qty",
      ExpressionAttributeValues: { ":qty": qty },
    }));
    return true;
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) return false;
    throw err;
  }
}

export class VersionConflictError extends Error {
  readonly current: Record<string, unknown> | undefined; // the item as it is now, if DynamoDB returned it
  constructor(current: Record<string, unknown> | undefined) {
    super("The item was changed by someone else since you read it");
    this.current = current;
  }
}

/** Optimistic locking: write only if the version is still the one you read, then bump it. */
export async function updatePrice(id: string, price: number, expectedVersion: number): Promise<Product> {
  try {
    const res = await ddb.send(new UpdateCommand({
      TableName: TABLE,
      Key: productKey(id),
      UpdateExpression: "SET price = :price, version = version + :one",
      ConditionExpression: "version = :expected",
      ExpressionAttributeValues: { ":price": price, ":one": 1, ":expected": expectedVersion },
      ReturnValues: "ALL_NEW",
      ReturnValuesOnConditionCheckFailure: "ALL_OLD",
    }));
    return res.Attributes as Product;
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      // Item is in the low-level AttributeValue format, even with the Document Client.
      throw new VersionConflictError(err.Item ? unmarshall(err.Item) : undefined);
    }
    throw err;
  }
}

/** All-or-nothing: take stock and create the order, or do neither. */
export async function placeOrder(orderId: string, productId: string, qty: number): Promise<"ok" | "out-of-stock" | "duplicate"> {
  try {
    await ddb.send(new TransactWriteCommand({
      ClientRequestToken: orderId, // makes retries within 10 minutes idempotent
      TransactItems: [
        {
          Update: {
            TableName: TABLE,
            Key: productKey(productId),
            UpdateExpression: "SET stock = stock - :qty",
            ConditionExpression: "stock >= :qty",
            ExpressionAttributeValues: { ":qty": qty },
          },
        },
        {
          Put: {
            TableName: TABLE,
            Item: { pk: `ORDER#${orderId}`, sk: "META", productId, qty, createdAt: new Date().toISOString() },
            ConditionExpression: "attribute_not_exists(pk)",
          },
        },
      ],
    }));
    return "ok";
  } catch (err) {
    if (err instanceof TransactionCanceledException) {
      const [stock, order] = err.CancellationReasons ?? [];
      if (order?.Code === "ConditionalCheckFailed") return "duplicate";
      if (stock?.Code === "ConditionalCheckFailed") return "out-of-stock";
    }
    throw err;
  }
}

Running each pattern once against a product that starts at version 3:

run-updates.ts

// run-updates.ts
// Usage: AWS_REGION=us-east-1 TABLE_NAME=app npx tsx run-updates.ts
import { incrementViews, placeOrder, renameProduct, reserveStock, retag, updatePrice, VersionConflictError } from "./dynamodb-updates.js";

const renamed = await renameProduct("p-100", "Trail Running Shoe", 129);
console.log(`renamed: ${renamed.name}, version ${renamed.version}`);

console.log(`views: ${await incrementViews("p-100")}`);
await retag("p-100", ["trail", "sale"], ["new"]);
console.log(`reserve 3: ${await reserveStock("p-100", 3)}`);

try {
  await updatePrice("p-100", 119, renamed.version); // succeeds and bumps the version
  await updatePrice("p-100", 109, renamed.version); // stale version: fails
} catch (err) {
  if (err instanceof VersionConflictError) console.log(`conflict: item is now at version ${String(err.current?.version)}`);
  else throw err;
}

console.log(`order o-9001: ${await placeOrder("o-9001", "p-100", 2)}`);
console.log(`order o-9001 again: ${await placeOrder("o-9001", "p-100", 2)}`);
Terminal

npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb
npm install --save-dev tsx typescript @types/node
npm pkg set type=module

AWS_PROFILE=dev AWS_REGION=us-east-1 TABLE_NAME=app npx tsx run-updates.ts
# renamed: Trail Running Shoe, version 3
# views: 1
# reserve 3: true
# conflict: item is now at version 4
# order o-9001: ok
# order o-9001 again: ok

The second updatePrice call uses the version the first call already bumped, so it fails with the current item attached. The second placeOrder reuses the same ClientRequestToken within 10 minutes, so DynamoDB treats it as the same transaction and returns success without taking stock twice. After the 10-minute window, the order’s attribute_not_exists condition returns duplicate instead.

Which ReturnValues should you use?

Value Returns Typical use
NONE (default) Nothing Fire-and-forget updates
ALL_NEW The whole item after the update Return the updated resource from an API
UPDATED_NEW Only the changed attributes, after The new counter value
ALL_OLD / UPDATED_OLD The item or changed attributes, before Audit logs and undo

Return values cost no read capacity and are strongly consistent. ReturnValuesOnConditionCheckFailure: "ALL_OLD" is the other half: when a condition fails, the exception carries the current item, so you can explain the conflict without a second read. That item arrives in the low-level attribute-value format even through the Document Client, which is why updatePrice calls unmarshall.

Atomic counters: ADD or SET?

ADD #views :by and SET #views = if_not_exists(#views, :zero) + :by both increment without reading first, and both are safe under concurrency: DynamoDB applies writes to an item in the order it receives them. ADD is shorter and starts from 0 on its own; SET with if_not_exists makes the starting value explicit and reads like the arithmetic it performs.

Neither is idempotent. If a request times out after DynamoDB applied it and the SDK retries, the counter moves twice. AWS’s guidance is to accept that for things like page views and use conditional updates where over- or under-counting isn’t acceptable, such as money or stock. reserveStock is that conditional version: the stock >= :qty check means a retry can fail but never take stock the item doesn’t have. The guide to configure retry and timeout settings in AWS SDK for JavaScript v3 explains when the SDK retries. If a count only feeds a dashboard or an alarm and your application never reads it back, publish it as a custom CloudWatch metric with PutMetricData instead of writing to a table.

Optimistic locking with a version attribute

The pattern Martin Fowler describes as an optimistic offline lock maps directly onto a condition expression. Read the item with its version, let the user edit, then write with ConditionExpression: "version = :expected" and SET version = version + :one. If someone else saved first, the condition fails, and updatePrice throws a VersionConflictError holding the current item so the caller can reload or merge.

Failed conditions still cost: a write whose condition evaluates to false consumes write capacity based on the size of the existing item (at least 1 unit). Retrying a conflict in a tight loop is paid work, so re-read first and back off.

When do you need TransactWriteCommand?

A single UpdateCommand is atomic for one item. When two items must change together, such as taking stock and creating an order, use TransactWriteCommand. It groups up to 100 Put, Update, Delete and ConditionCheck actions on items in the same account and Region, with a 4 MB aggregate limit, and no two actions can touch the same item. If any condition fails, nothing is written and the SDK throws TransactionCanceledException, whose CancellationReasons list one entry per action in request order, with the code "None" for actions that were fine. That’s how placeOrder tells “out of stock” from “duplicate order”. The guide to DynamoDB transactions with AWS SDK v3 and TransactWriteItems covers the full request, idempotency tokens and every cancellation code.

For work that spans services rather than items, a queue is usually a better fit than a bigger transaction; the guide to send and receive SQS messages with AWS SDK v3 shows the consumer side. To react to item changes after they’re written, for example to sync a search index, the guide to process DynamoDB Streams in Lambda with TypeScript shows a typed stream handler.

Which IAM permissions do updates need?

UpdateCommand needs dynamodb:UpdateItem on the table. Transactions are authorized per action: the Update and Put inside placeOrder need dynamodb:UpdateItem and dynamodb:PutItem, and a ConditionCheck action would need dynamodb:ConditionCheckItem.

dynamodb-update-policy.json

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

To derive the list from your own code, see how to find the IAM actions your AWS SDK for JavaScript code needs, or paste the module into the IAM policy generator for TypeScript code. An AccessDeniedException that names PutItem during a transaction is the per-action check at work; the steps to troubleshoot AWS IAM access denied errors help from there.

Troubleshooting and common mistakes

  • ValidationException mentioning a reserved keyword. An attribute such as name, status or views is used directly in the expression. Use #name and ExpressionAttributeNames.
  • An update created a new, almost empty item. The key was wrong and UpdateItem upserted. Add attribute_exists(pk) to updates that must hit an existing item.
  • “Pass options.removeUndefinedValues=true”. A value in the Document Client input was undefined. Set the marshall option, as in the module, or drop the field.
  • “Pass a non-empty set”. The Document Client refuses to marshall an empty Set, and DynamoDB rejects empty sets in DELETE anyway. Skip the clause, as retag does.
  • ADD fails on an attribute. The existing attribute is a string or list, not a number or set; ADD only supports those two.
  • Counters drift upward. Retries of non-idempotent increments. Accept it, or switch to a conditional or transactional write with a ClientRequestToken.

Limits worth knowing: an item can’t exceed 400 KB after the update, UpdateItem changes exactly one item (it can’t take a filter), and BatchWriteItem doesn’t support updates at all. For bulk puts and deletes, the guide to DynamoDB BatchWriteItem with AWS SDK v3 covers the 25-item limit and UnprocessedItems. If a table is throttling updates, compare its settings with actual use using the script to find overprovisioned DynamoDB read and write capacity, and make sure accidental overwrites are recoverable by first running the script to enable DynamoDB point-in-time recovery on every table. Porting from Python? The boto3 to AWS SDK for JavaScript v3 converter drafts update_item calls as UpdateCommand. ChatWithCloud can also help you inspect a table before you write to it: ask “What are the key schema and item count of the app table?” from a read-only profile.

Frequently asked questions

What is the difference between UpdateItemCommand and UpdateCommand?

UpdateItemCommand in @aws-sdk/client-dynamodb takes values in attribute-value format such as { N: "1" }. UpdateCommand in @aws-sdk/lib-dynamodb takes plain JavaScript values and converts them for you. Both call the same UpdateItem API.

Does UpdateItem create the item if it doesn’t exist?

Yes. UpdateItem is an upsert. Add ConditionExpression: "attribute_exists(pk)" if the update must only touch existing items.

How do I increment a number in DynamoDB with SDK v3?

Use UpdateExpression: "ADD #n :one" or "SET #n = if_not_exists(#n, :zero) + :one" with ReturnValues: "UPDATED_NEW" to get the new value. Both are atomic but not idempotent.

How do I update multiple DynamoDB items at once?

There is no multi-item UpdateItem. Send one UpdateCommand per item, or use TransactWriteCommand for up to 100 items that must change together.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud