DEV Community

Cover image for DynamoDB with Lambda and CDK: Build a Guestbook API
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

DynamoDB with Lambda and CDK: Build a Guestbook API

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:

  1. Write a new entry into the guestbook sommerfest.
  2. 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

Diagram: DynamoDB with Lambda – table with partition key buchId and sort key eintragId, a query reads one partition

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-lambda and Jest, as set up in part 1
  • The HTTP API from part 2 with the $default stage
  • curl in 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
Enter fullscreen mode Exit fullscreen mode

@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}` });
  }
};
Enter fullscreen mode Exit fullscreen mode

The most important parts in detail:

  1. You create the client outside the handler. Lambda reuses an execution environment for many invocations, so the client is created only once.
  2. PutCommand writes a whole item. The condition expression attribute_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 with 409.
  3. QueryCommand reads only the partition of the requested guestbook. ScanIndexForward: false reverses the sort order, and Limit caps the page size.
  4. If DynamoDB returns a LastEvaluatedKey, there may be more entries. The function wraps it as a URL-safe cursor in the weiter (“next”) field. When the client sends it back, it ends up as ExclusiveStartKey in the next query.
  5. 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';
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

What happens here:

  1. TableV2 creates the table with buchId as partition key and eintragId as sort key. Both are strings. You do not define any other attributes; they simply arrive with the items.
  2. Billing.onDemand() selects pay-per-request billing. That is the default for TableV2 anyway, but I write it out so nobody has to guess.
  3. The produktiv (“production”) switch separates the learning project from the real thing. Without it, the table disappears with cdk destroy. With -c produktiv=true it 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).
  4. The TABELLE environment variable passes in the table name, which CloudFormation only assigns at deploy time.
  5. tabelle.grant(...) allows the function exactly PutItem and Query on this table, nothing else.
  6. 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 from event.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);
});
Enter fullscreen mode Exit fullscreen mode

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 },
      }),
    ],
  });
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx jest
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"}'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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..."
}
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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)