Skip to main content

Using DynamoDB with Express

Amazon DynamoDB is a fully managed NoSQL database. It handles provisioning, replication, backups, and scaling automatically. You don't manage servers, configure replicas, or worry about disk space.

When to choose DynamoDB over MongoDB:

  • You're already on AWS and want tight integration (IAM auth, CloudWatch, Lambda triggers)
  • You need single-digit millisecond reads at any scale
  • Your access patterns are well-defined and predictable
  • You want a truly serverless database (pay per request, scale to zero)

DynamoDB Core Concepts

ConceptWhat it means
TableA collection of items (like a MongoDB collection)
ItemA single record (like a MongoDB document)
Partition KeyRequired primary key that determines which partition stores the item
Sort KeyOptional second key that enables range queries within a partition
GSIGlobal Secondary Index — query by a non-primary key attribute

Create a Table

  1. Go to AWS Console → DynamoDB → Create table
  2. Enter a Table name (e.g., Users)
  3. Set Partition key to id (String)
  4. Choose On-demand capacity (pay per request — best for variable traffic)
  5. Click Create table

Install

npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb dotenv
  • @aws-sdk/client-dynamodb — low-level DynamoDB client
  • @aws-sdk/lib-dynamodbDynamoDBDocumentClient that auto-converts JS values to/from DynamoDB's format

Configure the Client

config/dynamodb.js
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});

// DocumentClient handles marshalling JS objects ↔ DynamoDB format automatically
const db = DynamoDBDocumentClient.from(client);

export default db;

CRUD Operations

Create a utility for each table

models/user.js
import {
PutCommand,
GetCommand,
UpdateCommand,
DeleteCommand,
ScanCommand,
QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import db from "../config/dynamodb.js";
import { randomUUID } from "crypto";

const TABLE = "Users";

export async function createUser(data) {
const user = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...data,
};

await db.send(new PutCommand({ TableName: TABLE, Item: user }));
return user;
}

export async function getUserById(id) {
const result = await db.send(
new GetCommand({ TableName: TABLE, Key: { id } })
);
return result.Item || null;
}

export async function updateUser(id, updates) {
const keys = Object.keys(updates);

// Build UpdateExpression dynamically from the updates object
const UpdateExpression = "SET " + keys.map((k) => `#${k} = :${k}`).join(", ");
const ExpressionAttributeNames = Object.fromEntries(keys.map((k) => [`#${k}`, k]));
const ExpressionAttributeValues = Object.fromEntries(keys.map((k) => [`:${k}`, updates[k]]));

const result = await db.send(
new UpdateCommand({
TableName: TABLE,
Key: { id },
UpdateExpression,
ExpressionAttributeNames,
ExpressionAttributeValues,
ReturnValues: "ALL_NEW",
})
);

return result.Attributes;
}

export async function deleteUser(id) {
await db.send(new DeleteCommand({ TableName: TABLE, Key: { id } }));
}

export async function listUsers() {
const result = await db.send(new ScanCommand({ TableName: TABLE }));
return result.Items || [];
}
Avoid Scan in production

ScanCommand reads every item in the table — it's slow and expensive for large tables. Use QueryCommand with indexes for production workloads.

Express Routes

routes/users.js
import express from "express";
import {
createUser,
getUserById,
updateUser,
deleteUser,
listUsers,
} from "../models/user.js";

const router = express.Router();

router.get("/", async (req, res) => {
try {
const users = await listUsers();
res.json(users);
} catch (err) {
res.status(500).json({ message: "Failed to fetch users" });
}
});

router.get("/:id", async (req, res) => {
try {
const user = await getUserById(req.params.id);
if (!user) return res.status(404).json({ message: "User not found" });
res.json(user);
} catch (err) {
res.status(500).json({ message: "Failed to fetch user" });
}
});

router.post("/", async (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ message: "name and email are required" });
}
try {
const user = await createUser({ name, email });
res.status(201).json(user);
} catch (err) {
res.status(500).json({ message: "Failed to create user" });
}
});

router.patch("/:id", async (req, res) => {
try {
const updated = await updateUser(req.params.id, req.body);
res.json(updated);
} catch (err) {
res.status(500).json({ message: "Failed to update user" });
}
});

router.delete("/:id", async (req, res) => {
try {
await deleteUser(req.params.id);
res.json({ message: "User deleted" });
} catch (err) {
res.status(500).json({ message: "Failed to delete user" });
}
});

export default router;

Querying with a GSI

If you want to look up users by email (not the partition key), create a Global Secondary Index (GSI) on the email attribute:

  1. Go to DynamoDB → Your Table → Indexes → Create index
  2. Partition key: email, Index name: email-index

Then query by email:

models/user.js
export async function getUserByEmail(email) {
const result = await db.send(
new QueryCommand({
TableName: TABLE,
IndexName: "email-index",
KeyConditionExpression: "email = :email",
ExpressionAttributeValues: { ":email": email },
})
);
return result.Items?.[0] || null;
}

Conditional Writes

DynamoDB supports condition expressions to prevent race conditions — for example, only create a user if one with that email doesn't already exist:

export async function createUserIfNotExists(data) {
const user = { id: randomUUID(), createdAt: new Date().toISOString(), ...data };

try {
await db.send(
new PutCommand({
TableName: TABLE,
Item: user,
ConditionExpression: "attribute_not_exists(id)",
})
);
return user;
} catch (err) {
if (err.name === "ConditionalCheckFailedException") {
throw new Error("User already exists");
}
throw err;
}
}

IAM Permissions for DynamoDB

{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789:table/Users",
"arn:aws:dynamodb:us-east-1:123456789:table/Users/index/*"
]
}

Key Takeaways

  • Always use DynamoDBDocumentClient — it handles JS ↔ DynamoDB type marshalling automatically
  • Design your partition key and sort key around your access patterns, not your data shape
  • Avoid ScanCommand on large tables — use QueryCommand with GSIs for specific lookups
  • Use ConditionExpression for safe concurrent writes (e.g., uniqueness checks)
  • Choose On-demand capacity for unpredictable workloads; switch to Provisioned once traffic is stable and you want to optimize cost