DynamoDB with Lambda is, for me, the combination where serverless first becomes truly useful. In part 2 your API did answer, but it forgot what it had just said. Every request started from zero. Orders, comments, settings and forms, however, need a place to stay.
Today, therefore, your project gets a memory: a small guestbook. Anyone can write an entry through the HTTP API, and the newest entries come back page by page. Along the way you learn how to shape the table, use the AWS SDK v3 and grant only the permissions the function needs.
In short
DynamoDB is a serverless database you create in the CDK with a few lines; a Lambda function uses it over HTTPS and IAM, without a connection pool. If you choose the key to match your queries, you read with a cheap query instead of an expensive scan. The biggest stumbling block is designing the table like a relational database and thinking about queries afterwards.
What you will learn in this article
- Choose a partition key and sort key so your reads work with a query
- Create a DynamoDB table with TableV2 in the CDK, including protection for production
- Write, query and paginate with the document client from the AWS SDK v3
- Allow the function only PutItem and Query and lock that in with a test
- Estimate the cost of on-demand tables realistically
Why DynamoDB with Lambda fits so well
Amazon DynamoDB is a NoSQL database that AWS runs entirely for you. You do not create an instance, pick a size or install updates. A table contains items, which are roughly the rows. Each item consists of attributes, the fields. Apart from the key you define nothing up front: two items in the same table may have completely different attributes.
For Lambda, however, this is pleasant for two reasons. First, your function talks to DynamoDB through plain HTTPS calls. There is no connection pool that overflows when many function instances run in parallel, and no password you have to hide somewhere. Second, access is controlled through IAM, using the same roles and permissions the CDK creates for your function anyway. In on-demand mode you also pay per read and write, much like Lambda charges per invocation. A table nobody uses only costs its storage.
Understanding partition key and sort key
The most important concept in DynamoDB is the primary key. It comes in two flavours. The simple one consists of a partition key only. DynamoDB runs its value through a hash function and uses the result to decide which physical partition stores the item. With this flavour every value must be unique.
The composite flavour adds a sort key. Many items may then share the same partition key as long as their sort keys differ. These items are stored together and ordered by the sort key. That is exactly what makes them so valuable: a single query fetches all items of one partition key, optionally narrowed to a range of the sort key and in ascending or descending order. A scan, by contrast, reads the whole table and filters afterwards. With ten items you will not notice the difference; with ten million you will see it on your bill.
First the queries, then the table
In a relational database you usually design the tables first and later write any SQL you like against them. With DynamoDB you flip that order. You first write down which questions your application will ask, and then cut the key to fit. Our guestbook asks exactly two:
- Write a new entry into the guestbook
sommerfest. - Show the newest entries from
sommerfest, 20 per page, with a way to page further.
From there, the key almost designs itself. We use buchId (German for “book ID”) as the partition key, because we always ask within one guestbook. For the sort key we pick an eintragId (“entry ID”) that starts with an ISO timestamp, such as 2026-10-25T09:14:03.120Z#a1b2c3d4. ISO timestamps sort correctly as text, so a descending query automatically returns the newest entries first. The random suffix makes sure that two entries written in the same millisecond do not collide.
| Attribute | Role | Example |
|---|---|---|
buchId |
partition key | sommerfest |
eintragId |
sort key | 2026-10-25T09:14:03.120Z#a1b2c3d4 |
name |
regular attribute | Sascha |
text |
regular attribute | Thanks for coming! |
erstelltAm |
regular attribute (“created at”) | 2026-10-25T09:14:03.120Z |
The data model: all entries of one guestbook live in one partition, sorted by time. A query reads exactly that slice.
Prerequisites
You need the hallo-serverless project from part 1 and part 2 with the HTTP API. Specifically:
- Node.js 22 or newer and an AWS account with the AWS CLI configured (profile with a region)
- The CDK project in TypeScript with esbuild,
@types/aws-lambdaand Jest, as set up in part 1 - The HTTP API from part 2 with the
$defaultstage -
curlin your terminal; on Windows, Git Bash or WSL work well
If you are starting right here, a fresh project created with npx cdk init app --language typescript plus an HTTP API is enough. The DynamoDB constructs already ship with aws-cdk-lib; the only new pieces are the SDK packages for the function code.
DynamoDB with Lambda step by step
Step 1: Install the SDK packages
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev aws-sdk-client-mock
@aws-sdk/client-dynamodb is the actual client. @aws-sdk/lib-dynamodb puts the document client on top. It translates ordinary JavaScript objects into the format DynamoDB expects internally. Without it you would have to wrap every value with its type, so { "S": "Sascha" } instead of just "Sascha". Finally, you only need aws-sdk-client-mock for the tests.
One detail surprises many people: all supported Node.js runtimes in Lambda already include the AWS SDK v3, though in a fixed minor version that is not always the newest. NodejsFunction takes advantage of that and leaves @aws-sdk/* out of your bundle by default. Your function stays tiny as a result, around 3 KB in my case. If you want exactly the version from your package-lock.json, set bundleAwsSDK: true in the bundling options. The package gets bigger, but you test and run the same version.
Step 2: Write the guestbook handler
Create the file lambda/eintraege.ts (“entries”). It serves both routes, validates the input and talks to the table whose name it reads from the TABELLE (“table”) environment variable.
// lambda/eintraege.ts
import { randomUUID } from 'node:crypto';
import type {
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
} from '@aws-sdk/lib-dynamodb';
// Create once per execution environment, not on every invocation
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const MAX_NAME = 50;
const MAX_TEXT = 500;
const BUCH_ID = /^[a-z0-9-]{1,40}$/;
type Eintrag = {
buchId: string; // partition key
eintragId: string; // sort key: timestamp + random part
name: string;
text: string;
erstelltAm: string;
};
const json = (
statusCode: number,
body: unknown,
): APIGatewayProxyStructuredResultV2 => ({
statusCode,
headers: { 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify(body),
});
const leseBody = (event: APIGatewayProxyEventV2): string => {
if (!event.body) return '';
return event.isBase64Encoded
? Buffer.from(event.body, 'base64').toString('utf8')
: event.body;
};
// The cursor is the LastEvaluatedKey, URL-safe encoded
const kodiereCursor = (key: Record<string, unknown>): string =>
Buffer.from(JSON.stringify(key)).toString('base64url');
const dekodiereCursor = (
cursor: string,
buchId: string,
): Record<string, string> | undefined => {
try {
const key = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
if (key?.buchId === buchId && typeof key.eintragId === 'string') {
return { buchId, eintragId: key.eintragId };
}
} catch {
// invalid cursor, answered with 400 below
}
return undefined;
};
const neuerEintrag = async (
tabelle: string,
buchId: string,
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> => {
let daten: unknown;
try {
daten = JSON.parse(leseBody(event) || '{}');
} catch {
return json(400, { fehler: 'Body is not valid JSON' });
}
if (typeof daten !== 'object' || daten === null || Array.isArray(daten)) {
return json(400, { fehler: 'Body must be a JSON object' });
}
const { name, text } = daten as { name?: unknown; text?: unknown };
if (typeof name !== 'string' || !name.trim() || name.trim().length > MAX_NAME) {
return json(400, { fehler: `name is required and has at most ${MAX_NAME} characters` });
}
if (typeof text !== 'string' || !text.trim() || text.trim().length > MAX_TEXT) {
return json(400, { fehler: `text is required and has at most ${MAX_TEXT} characters` });
}
const erstelltAm = new Date().toISOString();
const eintrag: Eintrag = {
buchId,
eintragId: `${erstelltAm}#${randomUUID().slice(0, 8)}`,
name: name.trim(),
text: text.trim(),
erstelltAm,
};
try {
await db.send(
new PutCommand({
TableName: tabelle,
Item: eintrag,
// Safety net: never overwrite an existing entry
ConditionExpression: 'attribute_not_exists(eintragId)',
}),
);
} catch (fehler) {
if ((fehler as Error).name === 'ConditionalCheckFailedException') {
return json(409, { fehler: 'Entry already exists, please send again' });
}
throw fehler;
}
return json(201, eintrag);
};
const listeEintraege = async (
tabelle: string,
buchId: string,
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> => {
const parameter = event.queryStringParameters ?? {};
const limit = parameter.limit === undefined ? 20 : Number(parameter.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return json(400, { fehler: 'limit must be a number between 1 and 50' });
}
let startKey: Record<string, string> | undefined;
if (parameter.cursor) {
startKey = dekodiereCursor(parameter.cursor, buchId);
if (!startKey) return json(400, { fehler: 'cursor is invalid' });
}
const ergebnis = await db.send(
new QueryCommand({
TableName: tabelle,
KeyConditionExpression: 'buchId = :buchId',
ExpressionAttributeValues: { ':buchId': buchId },
ScanIndexForward: false, // newest entries first
Limit: limit,
ExclusiveStartKey: startKey,
}),
);
return json(200, {
eintraege: (ergebnis.Items ?? []) as Eintrag[],
weiter: ergebnis.LastEvaluatedKey
? kodiereCursor(ergebnis.LastEvaluatedKey)
: null,
});
};
export const handler = async (
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> => {
const tabelle = process.env.TABELLE;
if (!tabelle) throw new Error('Environment variable TABELLE is missing');
const buchId = event.pathParameters?.buchId ?? '';
if (!BUCH_ID.test(buchId)) {
return json(400, { fehler: 'buchId: only a-z, 0-9 and hyphen, at most 40 characters' });
}
console.log(
JSON.stringify({
level: 'info',
msg: 'request',
route: event.routeKey,
buchId,
requestId: event.requestContext.requestId,
}),
);
switch (event.routeKey) {
case 'POST /buecher/{buchId}/eintraege':
return neuerEintrag(tabelle, buchId, event);
case 'GET /buecher/{buchId}/eintraege':
return listeEintraege(tabelle, buchId, event);
default:
return json(404, { fehler: `Unknown route: ${event.routeKey}` });
}
};
The most important parts in detail:
- You create the client outside the handler. Lambda reuses an execution environment for many invocations, so the client is created only once.
-
PutCommandwrites a whole item. The condition expressionattribute_not_exists(eintragId)is a safety net: if the key already existed, DynamoDB rejects the write instead of silently overwriting the old entry. The function then answers with409. -
QueryCommandreads only the partition of the requested guestbook.ScanIndexForward: falsereverses the sort order, andLimitcaps the page size. - If DynamoDB returns a
LastEvaluatedKey, there may be more entries. The function wraps it as a URL-safe cursor in theweiter(“next”) field. When the client sends it back, it ends up asExclusiveStartKeyin the next query. - The handler checks the cursor before using it. A cursor from another guestbook or garbled text leads to
400, not to an error message from DynamoDB.
By the way, the cursor is not a secret: anyone can decode it and read the key. For a guestbook that does not matter. If your keys contain data callers should not see, encrypt or sign the cursor.
Step 3: Add table, function and permissions to the stack
Open lib/hallo-serverless-stack.ts and add one line to the imports at the top:
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
Insert the following block directly after the two api.addRoutes calls from part 2, before the CfnOutput lines. Function, log group and integration follow the same pattern as in the first two parts.
// lib/hallo-serverless-stack.ts (excerpt: insert part 3 right after the routes from part 2)
// --- Part 3: guestbook table in DynamoDB ---
// With "-c produktiv=true" the table survives deleting the stack
const produktiv = this.node.tryGetContext('produktiv') === 'true';
const tabelle = new dynamodb.TableV2(this, 'GaestebuchTabelle', {
partitionKey: { name: 'buchId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'eintragId', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: produktiv },
deletionProtection: produktiv,
removalPolicy: produktiv ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
});
const eintraegeLogs = new logs.LogGroup(this, 'EintraegeFunktionLogs', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const eintraegeFunktion = new NodejsFunction(this, 'EintraegeFunktion', {
entry: path.join(__dirname, '..', 'lambda', 'eintraege.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
timeout: cdk.Duration.seconds(10),
logGroup: eintraegeLogs,
description: 'Serverless from Scratch, part 3: guestbook with DynamoDB',
environment: { TABELLE: tabelle.tableName },
bundling: { minify: true },
});
// Only the two actions the handler really needs
tabelle.grant(eintraegeFunktion, 'dynamodb:PutItem', 'dynamodb:Query');
api.addRoutes({
path: '/buecher/{buchId}/eintraege',
methods: [apigwv2.HttpMethod.GET, apigwv2.HttpMethod.POST],
integration: new HttpLambdaIntegration('EintraegeIntegration', eintraegeFunktion),
});
new cdk.CfnOutput(this, 'TabellenName', {
value: tabelle.tableName,
description: 'Name of the DynamoDB table',
});
What happens here:
-
TableV2creates the table withbuchIdas partition key andeintragIdas sort key. Both are strings. You do not define any other attributes; they simply arrive with the items. -
Billing.onDemand()selects pay-per-request billing. That is the default for TableV2 anyway, but I write it out so nobody has to guess. - The
produktiv(“production”) switch separates the learning project from the real thing. Without it, the table disappears withcdk destroy. With-c produktiv=trueit is retained, protected against accidental deletion and has point-in-time recovery, a continuous backup from which you can restore any moment of the last 35 days (default value). - The
TABELLEenvironment variable passes in the table name, which CloudFormation only assigns at deploy time. -
tabelle.grant(...)allows the function exactlyPutItemandQueryon this table, nothing else. - The new route
/buecher/{buchId}/eintraege(“books/…/entries”) accepts GET and POST. The part in curly braces is a path parameter that the handler reads fromevent.pathParameters.
A small decision of principle on the second-to-last point. Many examples use tabelle.grantReadWriteData(funktion). That is convenient, but it also allows Scan, DeleteItem, BatchWriteItem and quite a bit more. I prefer to give a function only what its code actually calls. If UpdateItem comes along later, you add the action deliberately, and the stack test below reminds you to.
When you look at the generated template later, you will trip over the resource type AWS::DynamoDB::GlobalTable. That is not a mistake: TableV2 always builds on this type, even with a single region. This way you can add further regions as replicas later without recreating the table.
Step 4: Test handler and stack
The handler tests run without an AWS account. aws-sdk-client-mock intercepts every call of the document client, returns prepared responses and records the parameters the function sent to DynamoDB.
// test/eintraege.test.ts
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import { mockClient } from 'aws-sdk-client-mock';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
} from '@aws-sdk/lib-dynamodb';
import { handler } from '../lambda/eintraege';
const ddb = mockClient(DynamoDBDocumentClient);
beforeEach(() => {
ddb.reset();
process.env.TABELLE = 'test-tabelle';
});
// Minimal payload format 2.0 event for the guestbook routes
const event = (
methode: 'GET' | 'POST',
extra: Partial<APIGatewayProxyEventV2> = {},
): APIGatewayProxyEventV2 =>
({
version: '2.0',
routeKey: `${methode} /buecher/{buchId}/eintraege`,
pathParameters: { buchId: 'sommerfest' },
headers: {},
isBase64Encoded: false,
requestContext: { requestId: 'req-1' },
...extra,
}) as unknown as APIGatewayProxyEventV2;
const body = (antwort: { body?: string }) => JSON.parse(antwort.body ?? '{}');
test('POST stores an entry with a condition', async () => {
ddb.on(PutCommand).resolves({});
const antwort = await handler(
event('POST', { body: JSON.stringify({ name: 'Sascha', text: 'Great party!' }) }),
);
expect(antwort.statusCode).toBe(201);
const aufruf = ddb.commandCalls(PutCommand)[0].args[0].input;
expect(aufruf.TableName).toBe('test-tabelle');
expect(aufruf.Item?.buchId).toBe('sommerfest');
expect(aufruf.Item?.eintragId).toMatch(/^\d{4}-\d{2}-\d{2}T.+#[0-9a-f]{8}$/);
expect(aufruf.ConditionExpression).toBe('attribute_not_exists(eintragId)');
});
test('POST without text returns 400 and writes nothing', async () => {
const antwort = await handler(event('POST', { body: JSON.stringify({ name: 'Sascha' }) }));
expect(antwort.statusCode).toBe(400);
expect(ddb.commandCalls(PutCommand)).toHaveLength(0);
});
test('failed condition becomes 409', async () => {
ddb.on(PutCommand).rejects(
new ConditionalCheckFailedException({ message: 'exists', $metadata: {} }),
);
const antwort = await handler(
event('POST', { body: JSON.stringify({ name: 'A', text: 'B' }) }),
);
expect(antwort.statusCode).toBe(409);
});
test('GET queries newest entries first and returns a cursor', async () => {
ddb.on(QueryCommand).resolves({
Items: [{ buchId: 'sommerfest', eintragId: '2026-10-25T10:00:00.000Z#abcd1234' }],
LastEvaluatedKey: { buchId: 'sommerfest', eintragId: '2026-10-25T10:00:00.000Z#abcd1234' },
});
const antwort = await handler(event('GET', { queryStringParameters: { limit: '1' } }));
expect(antwort.statusCode).toBe(200);
const eingabe = ddb.commandCalls(QueryCommand)[0].args[0].input;
expect(eingabe.ScanIndexForward).toBe(false);
expect(eingabe.Limit).toBe(1);
expect(typeof body(antwort).weiter).toBe('string');
});
test('the cursor leads to the next page', async () => {
ddb.on(QueryCommand).resolves({ Items: [] });
const key = { buchId: 'sommerfest', eintragId: '2026-10-25T10:00:00.000Z#abcd1234' };
const cursor = Buffer.from(JSON.stringify(key)).toString('base64url');
const antwort = await handler(event('GET', { queryStringParameters: { cursor } }));
expect(antwort.statusCode).toBe(200);
expect(ddb.commandCalls(QueryCommand)[0].args[0].input.ExclusiveStartKey).toEqual(key);
expect(body(antwort).weiter).toBeNull();
});
test('cursor from another book is rejected', async () => {
const fremd = Buffer.from(JSON.stringify({ buchId: 'anderes', eintragId: 'x' })).toString('base64url');
const antwort = await handler(event('GET', { queryStringParameters: { cursor: fremd } }));
expect(antwort.statusCode).toBe(400);
expect(ddb.commandCalls(QueryCommand)).toHaveLength(0);
});
test('invalid buchId returns 400', async () => {
const antwort = await handler(event('GET', { pathParameters: { buchId: 'Bad ID!' } }));
expect(antwort.statusCode).toBe(400);
});
The stack test checks the template. It raises the alarm if someone rebuilds the key, loosens the permissions or removes the production protection.
// test/gaestebuch-stack.test.ts
import * as cdk from 'aws-cdk-lib/core';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { HalloServerlessStack } from '../lib/hallo-serverless-stack';
const template = (context: Record<string, string> = {}) => {
const app = new cdk.App({ context });
return Template.fromStack(new HalloServerlessStack(app, 'TestStack'));
};
test('table with buchId and eintragId, on-demand', () => {
template().hasResourceProperties('AWS::DynamoDB::GlobalTable', {
KeySchema: [
{ AttributeName: 'buchId', KeyType: 'HASH' },
{ AttributeName: 'eintragId', KeyType: 'RANGE' },
],
BillingMode: 'PAY_PER_REQUEST',
});
});
test('function may only use PutItem and Query', () => {
template().hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Action: ['dynamodb:PutItem', 'dynamodb:Query'],
Effect: 'Allow',
}),
]),
},
});
});
test('guestbook routes exist', () => {
const t = template();
t.hasResourceProperties('AWS::ApiGatewayV2::Route', {
RouteKey: 'GET /buecher/{buchId}/eintraege',
});
t.hasResourceProperties('AWS::ApiGatewayV2::Route', {
RouteKey: 'POST /buecher/{buchId}/eintraege',
});
});
test('learning mode: table is deleted with the stack', () => {
template().hasResource('AWS::DynamoDB::GlobalTable', {
DeletionPolicy: 'Delete',
});
});
test('produktiv=true: table is retained, PITR and deletion protection on', () => {
const t = template({ produktiv: 'true' });
t.hasResource('AWS::DynamoDB::GlobalTable', { DeletionPolicy: 'Retain' });
t.hasResourceProperties('AWS::DynamoDB::GlobalTable', {
Replicas: [
Match.objectLike({
DeletionProtectionEnabled: true,
PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
}),
],
});
});
npx tsc --noEmit
npx jest
Expected result: both new test files are green, twelve tests in total, plus the tests from the first two parts. If the permissions test fails, you probably used grantReadWriteData after all or changed the order of the actions.
Step 5: Deploy
npx cdk diff
npx cdk deploy
cdk diff shows you a new table, a function with role and log group, an integration, two routes and an IAM policy with exactly two DynamoDB actions. After you confirm the IAM changes, the outputs appear in the terminal. The output looks roughly like this (IDs and region are example values):
✅ HalloServerlessStack
Outputs:
HalloServerlessStack.ApiUrl = https://a1b2c3d4e5.execute-api.eu-central-1.amazonaws.com/
HalloServerlessStack.TabellenName = HalloServerlessStack-GaestebuchTabelleF6B83B19-1A2B3C4D5E6F
Step 6: Write and read entries
Put the API address into a variable again and write two entries into the sommerfest guestbook:
API_URL=$(aws cloudformation describe-stacks --stack-name HalloServerlessStack \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text)
curl -i -X POST "${API_URL}buecher/sommerfest/eintraege" \
-H 'content-type: application/json' \
-d '{"name":"Sascha","text":"Thanks for coming!"}'
curl -s -X POST "${API_URL}buecher/sommerfest/eintraege" \
-H 'content-type: application/json' \
-d '{"name":"Team","text":"See you next year!"}'
Both calls answer with 201 and the stored entry including its eintragId. Now the list, deliberately with just one entry per page so you can see the paging:
curl -s "${API_URL}buecher/sommerfest/eintraege?limit=1"
The response looks roughly like this, shortened and with example values:
{
"eintraege": [
{
"buchId": "sommerfest",
"eintragId": "2026-10-25T09:15:41.902Z#5e6f7a8b",
"name": "Team",
"text": "See you next year!",
"erstelltAm": "2026-10-25T09:15:41.902Z"
}
],
"weiter": "eyJidWNoSWQiOiJzb21tZXJmZXN0Iiwi..."
}
The newest entry comes first. Copy the value from weiter and fetch the next page:
curl -s "${API_URL}buecher/sommerfest/eintraege?limit=1&cursor=<value from weiter>"
Now Sascha’s entry shows up. If you ask for one more page, the list is empty and weiter is null. Getting a cursor even though there were exactly two entries is normal: when DynamoDB reaches the limit, it does not yet know whether more items follow. A guestbook that does not exist simply returns an empty list, and a buchId with capital letters or spaces gets a 400 from the handler.
🎬 An entry on its way into DynamoDB and back – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 7: Look at the data directly in DynamoDB
To double-check, it is worth looking past the API. With the AWS CLI you query the same partition as your function:
TABELLE=$(aws cloudformation describe-stacks --stack-name HalloServerlessStack \
--query "Stacks[0].Outputs[?OutputKey=='TabellenName'].OutputValue" --output text)
aws dynamodb query --table-name "$TABELLE" \
--key-condition-expression "buchId = :b" \
--expression-attribute-values '{":b":{"S":"sommerfest"}}' \
--no-scan-index-forward
The output shows why the document client is so handy: every value sits in a wrapper with its type, such as {"S": "Sascha"}. Alternatively, you can find the table in the AWS console under DynamoDB, “Explore items”. There you can view and edit entries while you are still practising.
Common mistakes with DynamoDB and Lambda
AccessDeniedException when writing or reading
The function calls an action its role does not allow. The error message names the action, for example dynamodb:UpdateItem. Add it to tabelle.grant(...) and to the stack test. If the error appears right after a deployment, also check that the environment variable really points to the new table.
ValidationException because of the key
Usually the item is missing the partition key or the sort key, or a key has the wrong type, for example a number instead of a string. Key attributes are mandatory in DynamoDB and must match the type in the table definition. A second classic: an attribute has the value undefined. By default the document client throws an error then. Either leave such fields out before writing, or allow it with the removeUndefinedValues option when creating the client.
A new entry is missing from the list right after writing
Query and GetItem read “eventually consistent” by default. A freshly written item may therefore be missing for a brief moment, in practice usually less than a second. If you need the guaranteed latest version, set ConsistentRead: true. That costs twice the read units.
The list gets slow and expensive over time
There is almost always a scan behind it, often hidden behind a filter. A scan reads the entire table and charges by the amount of data read, including items the filter throws away afterwards. If you cannot express a read as a query, your key does not match your access patterns. A different sort key or a global secondary index, an additional key on the same table, will help.
A single guestbook gets throttled
According to the AWS docs, each partition handles up to 3,000 read units and 1,000 write units per second. If almost all traffic lands on a single partition key, for example because there is only one huge guestbook, you will eventually hit that limit. For a learning project this is theory. With real load spikes, spread the writes across several keys, for example with a suffix per day or per shard.
All data is gone after cdk destroy
That is exactly how the learning mode is built. For anything that matters, deploy with -c produktiv=true. The table then survives deleting the stack and is additionally guarded by deletion protection. The flip side: if you really want to get rid of such a table, you have to switch off deletion protection deliberately first.
What does DynamoDB with Lambda cost?
In on-demand mode you pay per write and read unit. One write unit covers an item of up to 1 KB. One read unit covers a strongly consistent read of up to 4 KB; an eventually consistent read costs half. For a query, DynamoDB adds up the size of all returned items and rounds only the total up to the next 4 KB step. As a result, lists of many small items stay cheap.
In its examples, the pricing page uses USD 0.625 per million write units and USD 0.125 per million read units for US East (N. Virginia). Storage costs USD 0.25 per GB and month there. Prices in Frankfurt are slightly higher, so check the pricing page for your region. A worked example: 100,000 new entries a month cost about 6 cents. One million list requests with 20 small entries each, together under 8 KB, consume one read unit each and cost roughly 12.5 cents. On top of that come API Gateway and Lambda from the first two parts.
Two things are worth knowing. A failed condition still consumes write units, so it is not free. And according to the pricing page, the free tier includes, among other things, 25 GB of storage per month and region. You will hardly reach that limit with this project. Point-in-time recovery in production mode is billed extra based on table size. If you are unsure whether DynamoDB is the right storage for your idea at all, my AWS service finder helps you decide.
My take
For me, DynamoDB is the natural storage for serverless applications, but it does not replace every relational database. Its strength lies in known, recurring access patterns: one record by key, the newest entries of a group, all orders of a customer. For those, DynamoDB is fast, cheap and practically maintenance-free. If you need freely combinable analyses across many columns, joins or ad-hoc reports, you end up fighting the tool. A relational database or a separate analytics path is then the more honest choice.
I disagree with the popular advice to squeeze everything into one table with generic keys like PK and SK right from the start. Single-table design has its place when you have many entities with fixed access patterns and every millisecond counts. For beginners and small services, one table per business concept with meaningful attribute names is easier to understand and easier to change. More important than the pattern is the habit of writing down the access patterns before the first TableV2.
One more point close to my heart: permissions belong with the code. If you give a function blanket full access, you only find out what would have been possible when something breaks or leaks. Two actions instead of twenty cost you one line.
FAQ
Does a Lambda function need a VPC to use DynamoDB?
No. DynamoDB is reachable through a public HTTPS endpoint, and access is checked via IAM. If your function runs in a VPC without internet access for other reasons, you need a VPC endpoint for DynamoDB.
Should I bundle the AWS SDK into my Lambda function?
You do not have to, because the Node.js runtimes already include SDK v3. If you want exactly the version you tested with, bundle it yourself with bundleAwsSDK: true. The package gets bigger, but you no longer depend on the version in the runtime.
On-demand or provisioned capacity in DynamoDB?
For new projects and fluctuating load, on-demand is the easy choice because there is nothing to plan. Provisioned capacity with fixed units pays off mainly for steady, predictable load. Switching later is possible.
How large can an item be in DynamoDB?
At most 400 KB, attribute names and values combined. Store larger content such as images or PDFs in S3 and keep only the reference in DynamoDB.
Your next step
Build a third route DELETE /buecher/{buchId}/eintraege/{eintragId} that deletes a single entry. Write the test first, then add DeleteCommand to the handler and dynamodb:DeleteItem to the stack. Watch your permissions test remind you of the new action. That is exactly what it is there for.
I am curious: do you start new projects with DynamoDB or with a relational database, and what drives the decision? Let me know in the comments.
Continue the series: Go to the “Serverless from scratch” overview. Previous part: HTTP API with API Gateway and Lambda in CDK. Next part: SQS and EventBridge with Lambda: Decouple a Guestbook. If you want to react to changes in the table, DynamoDB Streams with AT_TIMESTAMP gives you a taste, and idempotency in AWS Lambda protects you against duplicate writes.
Sources
- Amazon DynamoDB Developer Guide: Core components (tables, items, primary keys)
- DynamoDB Developer Guide: Paginating table query results
- DynamoDB Developer Guide: Read and write operations (capacity consumption, conditions)
- DynamoDB Developer Guide: Best practices for designing partition keys
- DynamoDB Developer Guide: Constraints (item size, key lengths)
- Amazon DynamoDB Pricing for On-Demand Capacity
- AWS SDK for JavaScript v3: Using the DynamoDB document client
- AWS Lambda Developer Guide: Building Lambda functions with Node.js (included SDK)
- AWS CDK API Reference: aws-cdk-lib.aws_dynamodb.TableV2
- Tested with: CDK CLI 2.1146.1, aws-cdk-lib 2.274.0, TypeScript 7.0.2, esbuild 0.28.2, Jest 30, @aws-sdk/lib-dynamodb 3.1156.0, aws-sdk-client-mock 4.1.0 (tsc, 12 tests, cdk synth in learning and production mode; deploy and curl output marked as examples)
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)