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
| Concept | What it means |
|---|---|
| Table | A collection of items (like a MongoDB collection) |
| Item | A single record (like a MongoDB document) |
| Partition Key | Required primary key that determines which partition stores the item |
| Sort Key | Optional second key that enables range queries within a partition |
| GSI | Global Secondary Index — query by a non-primary key attribute |
Create a Table
- Go to AWS Console → DynamoDB → Create table
- Enter a Table name (e.g.,
Users) - Set Partition key to
id(String) - Choose On-demand capacity (pay per request — best for variable traffic)
- Click Create table
Install
- npm
- Yarn
- pnpm
- Bun
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb dotenv
yarn add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb dotenv
pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb dotenv
bun add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb dotenv
@aws-sdk/client-dynamodb— low-level DynamoDB client@aws-sdk/lib-dynamodb—DynamoDBDocumentClientthat 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:
- Go to DynamoDB → Your Table → Indexes → Create index
- 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
ScanCommandon large tables — useQueryCommandwith GSIs for specific lookups - Use
ConditionExpressionfor 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