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,...


