Blog post image for AWS DynamoDB CRUD Operations in Node.js with the AWS SDK v3 - 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.
Codesnippets

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

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

Published: Updated: 07 Mins read09 Mins listen
Markdown for AI(opens in a new tab)

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.

dynamo.ts
// dynamo.ts - CRUD helpers for a single DynamoDB table with the AWS SDK v3.
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {
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.
export const ddb = DynamoDBDocumentClient.from(baseClient, {
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.
export interface User {
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.

// Create or overwrite an item. Pass the object as-is, no attribute maps.
export async function putUser(user: User): Promise<void> {
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: user,
}),
);
}
// Fetch a single item by its partition + sort key.
export async function getUser(
pk: string,
sk: string,
): Promise<User | undefined> {
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.

// Update named fields only, and require that the item already exists.
export async function bumpLoginCount(pk: string, sk: string): Promise<User> {
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.
export async function deleteUser(pk: string, sk: string): Promise<void> {
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.

// Fetch every item under one partition key, e.g. all records for a user.
export async function queryByUser(pk: string): Promise<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.

import {ConditionalCheckFailedException} from '@aws-sdk/client-dynamodb';
async function main(): Promise<void> {
const key = {pk: 'USER#42', sk: 'PROFILE'};
await putUser({...key, name: 'Ada', email: '[email protected]'});
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

Terminal window
# 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.
export AWS_REGION=us-east-1
export TABLE_NAME=AppTable
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.

// Same partition key, different sort keys, one query returns them all.
await putUser({
pk: 'USER#42',
sk: 'PROFILE',
name: 'Ada',
});
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.

ApproachMarshallingAPI styleMaintainedBest for
SDK v3 DynamoDBDocumentClientAutomaticCommand objectsYesNew Node.js apps on the SDK v3
SDK v3 low-level DynamoDBClientManual mapsCommand objectsYesFine-grained control, edge cases
SDK v2 DynamoDB.DocumentClientAutomaticMethod callsMaintenance onlyLegacy code still on v2
An ORM/ODM like DynamooseAutomatic + schemaModel methodsYesTeams 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

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.

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.

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.

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.

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.

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

You might also enjoy

Check out some of our other posts on similar topics

Node.js Environment Variable Validation with Zod at Startup

Node.js Environment Variable Validation with Zod at Startup

Most Node.js apps treat process.env like a trusted friend. You reach into it whenever you need a value, assume the key is there, assume it's spelled right, and assume the string is actually the type

AWS Secrets Manager

AWS Secrets Manager

Need to load secrets in your Node.js app without exposing them? Here's how. If you're still storing API keys or database credentials in .env files or hardcoding them into your codebase, it's ti

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

If you've ever spent 20 minutes clicking through the AWS Console just to stop a handful of dev instances, you already know the pain. It's tedious, it doesn't scale, and one wrong click can ruin your a

PostgreSQL Query Optimization: Indexes, EXPLAIN ANALYZE & Execution Plans

PostgreSQL Query Optimization: Indexes, EXPLAIN ANALYZE & Execution Plans

Need to optimize slow PostgreSQL queries? Here's how with EXPLAIN ANALYZE and strategic indexing. Slow database queries kill application performance. But most developers don't know where the actu

Check S3 Bucket Existence

Check S3 Bucket Existence

Quick Tip Don’t let your deployment blow up because of a missing S3 bucket. This Bash script lets you check if a bucket exists before anything fails clean and simple. The Problem Missi

List S3 Buckets

List S3 Buckets

Overview Multi-Profile S3 Management Multi-Profile S3 Safari! Ever juggled multiple AWS accounts and needed a quick S3 bucket inventory across all of them? This Python script is your guid

6 related posts