LearnWithShobhan

DynamoDB UpdateItem in TypeScript: UpdateCommand, UpdateExpression and ExpressionAttributeNames explained

Shobhan Biswas··6 min read
>_From the course VidShare: 109 lessons, lifetime access.

PutItem is the first DynamoDB call everyone learns, and it is the wrong tool for half of real-world writes. When a Lambda reacts to an event ("the video file just landed in S3, mark it as uploaded"), it knows an id and one or two fields. It does not have the whole document. That is what UpdateItem is for.

Its syntax, though, looks strange the first time: strings like SET #status = :status, plus two extra objects full of # and : keys. This post explains why they exist, then builds a small, typed update helper and uses it in an S3-triggered Lambda.

PutItem replaces, UpdateItem patches

A stored item with id, title, description and status. PutItem with only id and status loses the title and description. UpdateItem with SET status changes only the status
PutItem writes the item you send, so every attribute you leave out disappears. UpdateItem only touches the attributes you name.

PutItem writes the whole item. If an item with that key already exists, it is replaced, and any attribute you did not include is gone. UpdateItem finds the item by its key and changes just the attributes in your update expression, leaving the rest alone.

The anatomy of an UpdateCommand

With the document client (see Connect Lambda to DynamoDB for the setup), changing one field looks like this:

import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
 
await docClient.send(
  new UpdateCommand({
    TableName: process.env.VIDEO_TABLE_NAME,
    Key: { id: "123" },
    UpdateExpression: "SET #status = :status",
    ExpressionAttributeNames: { "#status": "status" },
    ExpressionAttributeValues: { ":status": "UPLOADED" },
  }),
);
PartWhat it does
TableNameWhich table
KeyThe primary key of the item to change (partition key, plus sort key if the table has one)
UpdateExpressionWhat to do, written in DynamoDB's own mini-language
ExpressionAttributeNamesAliases for attribute names in the expression, written with #
ExpressionAttributeValuesAliases for the values, written with :

The expression SET #status = :status reads: "set the attribute behind #status to the value behind :status". Multiple changes are separated by commas: SET #title = :title, #description = :description.

Why the # and : placeholders?

The expression SET #status = :status with # mapping to an attribute name and : mapping to a value
Names and values never appear directly in the expression text. You alias them and describe the aliases separately.

You could imagine writing SET status = "UPLOADED". It does not work, for two reasons.

Reserved words. DynamoDB has a list of words with a meaning in its expression language, and status, name, size, data, comment and many more are on it. Using one directly gives a validation error that mentions "reserved keyword". Aliasing the name (#status) avoids the clash. Notice that status is exactly what our video item uses.

Values stay data. A value is always passed as a placeholder (:status), never pasted into the string. The expression is a fixed piece of text, and the data travels beside it, which keeps characters in your data from being mixed up with the expression syntax.

The practical rule: always alias every attribute name. You never need to look up the reserved-word list, and you never get surprised when a new attribute happens to be on it.

Build the expression from an object

Writing these three strings by hand for every update is repetitive and easy to get wrong. A small helper can build them from a plain object of changes:

export const buildUpdate = (attributes: Record<string, unknown>) => {
  const entries = Object.entries(attributes).filter(([, value]) => value !== undefined);
  if (entries.length === 0) throw new Error("Nothing to update");
 
  return {
    UpdateExpression: "SET " + entries.map((_, i) => `#k${i} = :v${i}`).join(", "),
    ExpressionAttributeNames: Object.fromEntries(entries.map(([key], i) => [`#k${i}`, key])),
    ExpressionAttributeValues: Object.fromEntries(entries.map(([, value], i) => [`:v${i}`, value])),
  };
};

For { status: "UPLOADED", title: "New" } it produces SET #k0 = :v0, #k1 = :v1 with matching name and value maps. Each attribute gets a numbered alias, so any attribute name is safe.

Type it with a generic

You want update("123", { status: "UPLOADED" }) to autocomplete the attribute names and reject { staus: "UPLOADED" }. That is what a TypeScript generic is for: a type placeholder you fill in later. A generic function shows the idea:

const print = <T,>(value: T): T => value;
print(1);       // returns type number
print("hi");    // returns type string

The same trick works for a base database class. T is the type of the item the class stores:

export abstract class Db<T extends Record<string, unknown>> {
  constructor(protected tableName: string) {}
 
  async update(id: string, attributes: Partial<T>) {
    const u = buildUpdate(attributes);
 
    await docClient.send(
      new UpdateCommand({
        TableName: this.tableName,
        Key: { id },
        UpdateExpression: u.UpdateExpression,
        ConditionExpression: "attribute_exists(#id)",
        ExpressionAttributeNames: { ...u.ExpressionAttributeNames, "#id": "id" },
        ExpressionAttributeValues: u.ExpressionAttributeValues,
      }),
    );
  }
}
 
export class VideoDb extends Db<Video> {}

Partial<T> means "any subset of the item's attributes". With VideoDb extends Db<Video>, calling videoDb.update("123", { status: "UPLOADED" }) is checked against the real Video type from your Zod schema, and { staus: "UPLOADED" } is a compile error.

Why the condition

ConditionExpression: "attribute_exists(#id)" is a small guard worth having: UpdateItem creates the item if it does not exist. Without the condition, an update for an id that was never saved silently creates a stub item with only a status. With it, DynamoDB rejects the call with a ConditionalCheckFailedException, which you can turn into a 404.

Use it in an S3-triggered Lambda

The VidShare flow uploads a video straight to S3 with a presigned URL, using the video's id as the object key. When the upload finishes, S3 calls a Lambda. That handler knows the key (which equals the id), so it only needs to flip the status:

import type { S3Handler } from "aws-lambda";
import { VideoDb } from "../../entity/video-db";
 
const videoDb = new VideoDb(process.env.VIDEO_TABLE_NAME!);
 
export const handler: S3Handler = async (event) => {
  for (const record of event.Records) {
    const id = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
    await videoDb.update(id, { status: "UPLOADED" });
  }
};

Two details are easy to miss. S3 delivers URL-encoded keys (spaces become +), so decode them before using the key as an id. And an event can contain several records, so loop instead of reading only the first. (What the event looks like, and how to wire the trigger with CDK, is covered in Trigger a Lambda on S3 upload.)

Test it test-first

Like any handler that talks to a database, this one is easiest to test by spying on the database method and checking what it was called with:

it("marks the video as uploaded", async () => {
  const mockUpdate = jest.spyOn(VideoDb.prototype, "update").mockResolvedValue();
 
  await handler(getEvent("test-bucket", "id-123") as any, {} as any, () => {});
 
  expect(mockUpdate).toHaveBeenCalledTimes(1);
  expect(mockUpdate.mock.calls[0][0]).toBe("id-123");
  expect(mockUpdate.mock.calls[0][1]).toEqual({ status: "UPLOADED" });
});

mock.calls[0][0] is the first argument of the first call, and [0][1] the second. The spying and mocking approach, including when not to mock, is explained in Test-driven development for AWS Lambda with Jest.

Key takeaways

  • ✓PutItem replaces the whole item. UpdateItem changes only the attributes you name.
  • ✓UpdateExpression says what to do, ExpressionAttributeNames aliases names with #, ExpressionAttributeValues aliases values with :.
  • ✓Many everyday names (status, name, size) are reserved words, so alias every attribute name.
  • ✓A small helper can build the three pieces from an object, and a generic base class types the attributes with Partial<T>.
  • ✓UpdateItem creates a missing item. Add ConditionExpression: attribute_exists(...) so an unknown id fails instead.

Build the whole pipeline

VidShare: Build a Video Streaming Platform on AWS takes this handler from a failing test to a deployed S3-triggered Lambda, then continues into video processing with MediaInfo and MediaConvert. 109 lessons, lifetime access, and three lessons are free to watch.

Free download

Linux command line cheat sheet

One printable page: navigation, files, permissions, pipes, processes, scripts and SSH.

Occasional emails with new tutorials and courses. Unsubscribe any time.

Keep reading