---
title: "AWS DynamoDB CRUD Operations in Node.js with the AWS SDK v3"
description: "A practical Node.js snippet covering DynamoDB put, get, update, delete, and query operations using the modern AWS SDK v3. Includes the DocumentClient pattern, single-table design basics, and error handling."
author: "Mohammad Abu Mattar"
canonical: https://mkabumattar.com/codesnippets/post/nodejs-dynamodb-crud-aws-sdk-v3
---

# AWS DynamoDB CRUD Operations in Node.js with the AWS SDK v3

**Quick Tip**

Wrap the low-level DynamoDB client in a `DynamoDBDocumentClient` so you pass plain JavaScript objects in and get plain objects back, then guard your writes with `ConditionExpression` and read by key with `QueryCommand`.

## The Problem

**The Problem**

The low-level DynamoDB API doesn't speak JavaScript. Every value has to be wrapped in a typed attribute map, so a simple `{ id: "42", count: 3 }` turns into `{ id: { S: "42" }, count: { N: "3" } }` on the way in, and you have to unwrap all of it on the way out. Write that marshalling by hand across a codebase and it becomes a steady drip of `ValidationException` errors and off-by-one type bugs.

### Why raw attribute maps hurt

The attribute-map format leaks into every call site. You can't just hand DynamoDB the object your app already has, you first translate it, then translate the response back, and you repeat that boilerplate for put, get, update, and query. It's easy to send a number as a string or forget a nested map, and DynamoDB rejects the whole request rather than coercing anything for you.

### The real-world impact

That friction shows up as slow feature work and brittle data access. New team members trip over the `{ S: ... }` wrappers, updates accidentally overwrite fields they never meant to touch, and someone reaches for `Scan` because it "just works," quietly reading the entire table on every call. The result is code that's harder to review and a bill that grows faster than the data.

## The Solution

**The Fix**

Use the AWS SDK v3 `DynamoDBDocumentClient`. It wraps the base `DynamoDBClient` and handles marshalling in both directions, so your CRUD code deals in plain objects. Pair it with a single table, address items by their partition and sort keys, use `UpdateCommand` to change only the fields you name, and add a `ConditionExpression` when a write must not clobber existing data.

**TL;DR**

- Wrap `DynamoDBClient` in `DynamoDBDocumentClient` so you never touch raw attribute maps.
- Use `PutCommand`, `GetCommand`, `UpdateCommand`, and `DeleteCommand` for CRUD, with `ConditionExpression` to keep writes safe.
- Read with `QueryCommand` on the partition key instead of scanning the whole table.

## Script Implementation

### Setup and the DocumentClient

Create the base client once, wrap it in a `DynamoDBDocumentClient`, and turn on the marshalling options that smooth over DynamoDB's quirks. Reuse this single instance across your app so connections get pooled.

```ts title="dynamo.ts" showLineNumbers
// dynamo.ts - CRUD helpers for a single DynamoDB table with the AWS SDK v3.

  DynamoDBDocumentClient,
  PutCommand,
  GetCommand,
  UpdateCommand,
  DeleteCommand,
  QueryCommand,
} from '@aws-sdk/lib-dynamodb';

const TABLE_NAME = process.env.TABLE_NAME ?? 'AppTable';

// One low-level client for the process; region comes from the environment.
const baseClient = new DynamoDBClient({});

// The DocumentClient marshals plain JS objects to/from attribute maps.

  marshallOptions: {
    // Drop undefined fields instead of erroring on them.
    removeUndefinedValues: true,
    // Store empty strings and sets as-is rather than converting to null.
    convertEmptyValues: false,
  },
});

// A tiny item shape for the examples: single-table keys plus data.

  pk: string; // partition key, e.g. "USER#42"
  sk: string; // sort key, e.g. "PROFILE"
  name: string;
  email: string;
  loginCount?: number;
}
```

### Create and read: put and get

`PutCommand` writes a whole item, overwriting any existing item with the same key. `GetCommand` fetches one item by its full primary key and returns a plain object, or `undefined` when nothing matches.

```ts
// Create or overwrite an item. Pass the object as-is, no attribute maps.

  await ddb.send(
    new PutCommand({
      TableName: TABLE_NAME,
      Item: user,
    }),
  );
}

// Fetch a single item by its partition + sort key.

  pk: string,
  sk: string,
): Promise {
  const {Item} = await ddb.send(
    new GetCommand({
      TableName: TABLE_NAME,
      Key: {pk, sk},
    }),
  );
  // Item is already a plain object, cast it to the known shape.
  return Item as User | undefined;
}
```

### Update and delete safely

`UpdateCommand` changes only the fields you name in the `UpdateExpression`, so you never overwrite the rest of the item. The `ConditionExpression` makes the update fail loudly if the item doesn't already exist, which stops accidental "upserts." `DeleteCommand` removes an item by key.

```ts
// Update named fields only, and require that the item already exists.

  const {Attributes} = await ddb.send(
    new UpdateCommand({
      TableName: TABLE_NAME,
      Key: {pk, sk},
      // ADD creates loginCount at 1 if missing, otherwise increments it.
      UpdateExpression: 'ADD loginCount :one',
      ConditionExpression: 'attribute_exists(pk)',
      ExpressionAttributeValues: {':one': 1},
      ReturnValues: 'ALL_NEW',
    }),
  );
  return Attributes as User;
}

// Delete an item by key. Idempotent: deleting a missing key is a no-op.

  await ddb.send(
    new DeleteCommand({
      TableName: TABLE_NAME,
      Key: {pk, sk},
    }),
  );
}
```

### Query by key instead of scanning

`QueryCommand` reads every item that shares a partition key, optionally narrowed by the sort key. This is the read you want almost every time, it touches only the matching items instead of the whole table the way `Scan` does.

```ts
// Fetch every item under one partition key, e.g. all records for a user.

  const items: User[] = [];
  let ExclusiveStartKey: Record<string, unknown> | undefined;

  // Loop to follow pagination until DynamoDB stops returning a cursor.
  do {
    const page = await ddb.send(
      new QueryCommand({
        TableName: TABLE_NAME,
        KeyConditionExpression: 'pk = :pk',
        ExpressionAttributeValues: {':pk': pk},
        ExclusiveStartKey,
      }),
    );
    items.push(...((page.Items ?? []) as User[]));
    ExclusiveStartKey = page.LastEvaluatedKey;
  } while (ExclusiveStartKey);

  return items;
}
```

### Entry point with error handling

Tie the helpers together in a small demo and catch the one error you actually expect: a failed `ConditionExpression`. DynamoDB reports it as a `ConditionalCheckFailedException`, which you handle rather than crash on.

```ts

async function main(): Promise<void> {
  const key = {pk: 'USER#42', sk: 'PROFILE'};

  await putUser({...key, name: 'Ada', email: 'ada@example.com'});
  console.log('created:', await getUser(key.pk, key.sk));

  try {
    const updated = await bumpLoginCount(key.pk, key.sk);
    console.log('login count is now', updated.loginCount);
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      console.error('update skipped: that user does not exist yet');
    } else {
      throw err; // Anything else is unexpected, let it surface.
    }
  }

  console.log('all records:', await queryByUser(key.pk));
  await deleteUser(key.pk, key.sk);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

## Usage and Benefits

**Why This Helps**

The `DocumentClient` deletes an entire category of bugs by letting you work in plain objects, and the CRUD helpers give every call site the same safe pattern: named updates, conditional writes, and key-based reads. You stop hand-writing attribute maps, stop accidentally overwriting fields, and stop reaching for `Scan` when a `Query` is right there.

### Real invocations

```shell
# Install the SDK v3 packages.
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

# Set the table and region, then run the compiled script.

node dynamo.js

# Or run the TypeScript directly during development.
npx tsx dynamo.ts
```

### Tuning for single-table design

The `pk`/`sk` pair is the heart of single-table design: you overload one table with many item types by encoding the type into the key. A user profile might use `pk = "USER#42"`, `sk = "PROFILE"`, while their orders use the same `pk` with `sk = "ORDER#1001"`. One `QueryCommand` on `USER#42` then pulls the profile and every order in a single call.

```ts
// Same partition key, different sort keys, one query returns them all.
await putUser({
  pk: 'USER#42',
  sk: 'PROFILE',
  name: 'Ada',
  email: 'ada@example.com',
});
await putUser({pk: 'USER#42', sk: 'ORDER#1001', name: 'order', email: '-'});

// begins_with narrows a query to just the orders under this user.
const orders = await ddb.send(
  new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
    ExpressionAttributeValues: {':pk': 'USER#42', ':prefix': 'ORDER#'},
  }),
);
```

## Comparison

How the SDK v3 `DocumentClient` stacks up against the other common ways to talk to DynamoDB from Node.js.

| Approach                          | Marshalling        | API style       | Maintained       | Best for                         |
| :-------------------------------- | :----------------- | :-------------- | :--------------- | :------------------------------- |
| SDK v3 `DynamoDBDocumentClient`   | Automatic          | Command objects | Yes              | New Node.js apps on the SDK v3   |
| SDK v3 low-level `DynamoDBClient` | Manual maps        | Command objects | Yes              | Fine-grained control, edge cases |
| SDK v2 `DynamoDB.DocumentClient`  | Automatic          | Method calls    | Maintenance only | Legacy code still on v2          |
| An ORM/ODM like Dynamoose         | Automatic + schema | Model methods   | Yes              | Teams wanting model abstractions |

The low-level client is worth dropping to only when you need something the `DocumentClient` doesn't expose. For everyday CRUD, the document client's automatic marshalling is the reason it exists.

## Frequently Asked Questions

> **What's the difference between DynamoDBClient and DynamoDBDocumentClient?**

`DynamoDBClient` is the low-level client: it speaks DynamoDB's native format, so every value is a typed attribute map like `{ S: "text" }` or `{ N: "3" }`. `DynamoDBDocumentClient` wraps it and adds marshalling in both directions, so you pass and receive plain JavaScript objects. You still create the base client, then build the document client from it with `DynamoDBDocumentClient.from(baseClient)`. Use the document client for CRUD and drop to the base client only for the rare feature it doesn't cover.

> **Why should I prefer Query over Scan?**

`Query` reads only the items that share a partition key, using the index directly, so it stays fast and cheap as the table grows. `Scan` reads every item in the table and then filters, which means its cost and latency scale with the whole dataset, not with the rows you want. Design your keys so the reads you need are `Query` calls. Keep `Scan` for genuine full-table jobs like exports or backfills, and even then paginate it.

> **How do I update a single field without overwriting the whole item?**

Use `UpdateCommand` with an `UpdateExpression` that names only the fields you're changing, such as `SET #n = :name` or `ADD loginCount :one`. That leaves every other attribute on the item untouched. `PutCommand`, by contrast, replaces the entire item, so any field you don't include is dropped. When a name collides with a DynamoDB reserved word, alias it through `ExpressionAttributeNames` (the `#n` above) so the expression still parses.

> **What does ConditionExpression protect against?**

A `ConditionExpression` makes a write happen only if the condition holds, and fail otherwise. `attribute_exists(pk)` blocks an update from silently creating a new item, `attribute_not_exists(pk)` blocks a put from overwriting an existing one, and a version check like `version = :expected` gives you optimistic locking. When the condition fails, DynamoDB throws `ConditionalCheckFailedException`, which you catch and handle instead of letting a bad write through.

> **How do I handle results that span multiple pages?**

DynamoDB returns at most 1 MB of data per `Query` or `Scan`. When there's more, the response includes a `LastEvaluatedKey` cursor. Pass it back as `ExclusiveStartKey` on the next call and loop until the response stops returning one, which is exactly what `queryByUser` does. Skip the loop and you silently read only the first page, a bug that hides until your data crosses the 1 MB boundary in production.

> **Do I still need the AWS SDK v2 DocumentClient?**

Only for code that already runs on it. The SDK v2 is in maintenance mode, so new work should use v3, which is modular (you install just `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb`), tree-shakeable, and actively developed. The v3 `DynamoDBDocumentClient` gives you the same automatic marshalling the v2 `DocumentClient` did, just with the command-object API. Migrate when you can, but there's no need to rush a rewrite that works.

## References

- [AWS SDK v3: @aws-sdk/lib-dynamodb (DocumentClient)](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-lib-dynamodb/)
- [AWS SDK v3: @aws-sdk/client-dynamodb](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-dynamodb/)
- [DynamoDB Developer Guide: Query operations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html)
- [DynamoDB Developer Guide: Condition expressions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html)
- [DynamoDB Developer Guide: Single-table design](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html)
- [AWS SDK for JavaScript v3 Developer Guide](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html)
