DEV Community

Cover image for Validate Lambda events with Powertools Parser and Zod
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Validate Lambda events with Powertools Parser and Zod

You should validate Lambda events, and you probably know that already. It still tends to slip down the list. The function works, the tests are green, and the sample event always looks the same. Until a partner renames a field, a frontend suddenly sends null, or someone drops a test message into the queue by hand. Then your log shows TypeError: Cannot read properties of undefined, and you spend half an hour hunting for the cause.

This topic keeps coming back to me because TypeScript gives you a false sense of safety at exactly this point. The APIGatewayProxyEventV2 type promises a body of type string. What is inside that string, the compiler cannot know. At runtime you get whatever arrives. In this article I show you how to check that boundary with the Parser from Powertools for AWS Lambda and Zod, so invalid data shows up early, clearly and without collateral damage.

In short

The Powertools Parser checks Lambda events against Zod schemas before your code touches them and hands you real types afterwards. For HTTP APIs you use it as middleware with safeParse and answer with a 400 yourself. The biggest pitfall is SQS: if you check the whole batch at once, a single broken message drags all the others down with it.

What you will learn in this article

  • Why TypeScript types alone do not protect you from broken events
  • How to check an HTTP body with an envelope and safeParse and answer cleanly with a 400
  • How to validate SQS messages one by one without losing the whole batch
  • How to deploy everything with the Serverless Framework and cover it with node:test

Why you should validate Lambda events

A Lambda function is a system boundary. Whatever arrives there comes from API Gateway, a queue, EventBridge or a third-party system you do not control. Type definitions such as those from @types/aws-lambda only describe the envelope that AWS guarantees. For the content, meaning your business data, there is no guarantee.

Without a check, this is what typically happens: the code reads a missing field, keeps calculating with undefined or writes half a record to the database. The error then surfaces somewhere completely different. That is why I follow a simple rule: data is checked exactly once, right where it enters the system. After that, the rest of the code may rely on the types.

Zod is a schema library for TypeScript. You describe what valid data looks like and get two things back: a runtime check and a matching TypeScript type. I showed how this works for environment variables in Validate environment variables with Zod. Today it is about the events themselves.

What the Powertools Parser brings along

The Parser is a module of Powertools for AWS Lambda (TypeScript), the toolkit I introduced in Lambda Powertools TypeScript: logs, metrics, traces. It offers three building blocks that you combine:

  • Ready-made schemas for the envelopes of AWS events, for example SqsSchema, EventBridgeSchema or APIGatewayProxyEventV2Schema. So you do not have to rebuild the structure of an SQS event yourself.
  • Envelopes that pull exactly the part you care about out of an event, such as the body of an HTTP request or the detail of an EventBridge event, and check only that part against your schema.
  • Helpers such as JSONStringified, which parses a JSON string first and then validates the result. You need this all the time, because HTTP bodies and SQS messages both arrive as strings.

You can use the Parser as Middy middleware, as a decorator on a class method or the classic way by hand with parse and safeParse. Middy is a small middleware engine for Lambda handlers. The example uses both approaches, because each one suits a different event source.

parse or safeParse?

parse throws a ParseError on invalid data. That is handy when an error should end processing anyway. safeParse, on the other hand, does not throw. It returns an object with success: true and data, or with success: false and error. For an HTTP API you almost always want the second option, because a validation error there is not a server error but a 400 response to the caller.

Diagram: validate Lambda events synchronously via middleware with 202/400 and asynchronously in a loop with batchItemFailures and DLQ

Two checkpoints: answer at once when synchronous, sort out each message on its own when asynchronous.

The example: rental requests for a bike rental shop

We build a small service for a fictional bike rental shop. Rental requests come in through an HTTP API, and requests from a partner portal land in an SQS queue. Both paths share the same schema. A request needs a bike ID in the format BK-0042, an email address, start and end as ISO timestamps and, optionally, a helmet.

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account and Serverless Framework v4 (for the deployment, the tests run without it)
  • Basic knowledge of TypeScript and Lambda

Step 1: Set up the project

mkdir bike-rental-validation && cd bike-rental-validation
npm init -y
npm pkg set type=module
npm install @aws-lambda-powertools/parser zod @middy/core
npm install -D typescript @types/node @types/aws-lambda
Enter fullscreen mode Exit fullscreen mode

One thing about versions: the Parser in version 2.36 expects Zod 4 as a peer dependency. If your project still uses Zod 3, plan that upgrade first. The tsconfig.json is deliberately plain:

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": ".",
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The schema as the single source of truth

The schema describes the business data and nothing else. It knows nothing about HTTP or SQS, and that is exactly what makes it reusable. With .refine you also check a rule across several fields: the end must be after the start. z.infer turns it into the TypeScript type, which you never have to maintain by hand again.

// src/rental-request.ts
import { z } from 'zod';

// A rental request for the bike rental shop – this is what a valid order looks like
export const RentalRequestSchema = z
  .object({
    bikeId: z.string().regex(/^BK-\d{4}$/, 'bikeId must look like BK-0042'),
    customerEmail: z.email(),
    from: z.iso.datetime({ offset: true }),
    until: z.iso.datetime({ offset: true }),
    helmet: z.boolean().default(false),
  })
  .refine((r) => Date.parse(r.until) > Date.parse(r.from), {
    message: 'until must be after from',
    path: ['until'],
  });

export type RentalRequest = z.infer<typeof RentalRequestSchema>;
Enter fullscreen mode Exit fullscreen mode

Validate the HTTP body with an envelope and safeParse

For the HTTP API I use the Parser as middleware. The ApiGatewayV2Envelope checks the envelope of the HTTP API event and passes only the body on to your schema. Since the body is a string, I wrap the schema in JSONStringified. With safeParse: true, the handler receives a result object instead of the event and decides on its own how to respond.

Step 3: Write the HTTP handler

// src/http-handler.ts
import middy from '@middy/core';
import { parser } from '@aws-lambda-powertools/parser/middleware';
import { ApiGatewayV2Envelope } from '@aws-lambda-powertools/parser/envelopes/api-gatewayv2';
import { JSONStringified } from '@aws-lambda-powertools/parser/helpers';
import type { ParsedResult } from '@aws-lambda-powertools/parser/types';
import type { APIGatewayProxyResultV2 } from 'aws-lambda';
import { z } from 'zod';
import { RentalRequestSchema, type RentalRequest } from './rental-request.js';

const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
  statusCode,
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(body),
});

const createRental = async (
  event: ParsedResult<unknown, RentalRequest>,
): Promise<APIGatewayProxyResultV2> => {
  if (!event.success) {
    // The cause is the ZodError listing every field that does not match
    const cause = event.error.cause;
    const issues =
      cause instanceof z.ZodError
        ? cause.issues.map((i) => ({ field: i.path.slice(1).join('.'), message: i.message }))
        : [{ field: '', message: 'Body is not valid JSON' }];
    return json(400, { error: 'Invalid rental request', issues });
  }

  const rental = event.data; // fully typed from here on
  return json(202, { status: 'accepted', bikeId: rental.bikeId, helmet: rental.helmet });
};

export const handler = middy(createRental).use(
  parser({
    schema: JSONStringified(RentalRequestSchema),
    envelope: ApiGatewayV2Envelope,
    safeParse: true,
  }),
);
Enter fullscreen mode Exit fullscreen mode

Two details matter to me here. First, on errors the handler returns a list of the affected fields, so the frontend can show them right next to the form. The paths start with body, which is why I cut off the first element. Second, the service answers with 202 instead of 201, because the actual reservation happens asynchronously. That fits the pattern I also use for receiving webhooks with Lambda.

An invalid request then gets this answer from the API:

{
  "error": "Invalid rental request",
  "issues": [
    { "field": "bikeId", "message": "bikeId must look like BK-0042" },
    { "field": "until", "message": "until must be after from" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Also keep an eye on isBase64Encoded. For JSON requests to an HTTP API the body is usually plain text. If a client sends binary data or an unusual content type, API Gateway may encode the body as Base64, and JSONStringified fails. For a pure JSON API that is a welcome 400 response; for file uploads you need a different approach.

Check SQS messages one by one instead of losing the batch

Now comes the part that many examples leave out. For SQS there is an SqsEnvelope that checks all messages of a batch against your schema at once. It looks elegant but has a side effect: if even one message is invalid, the check fails for the whole batch. If the function then throws, Lambda makes all messages visible again, including the nine valid ones. They keep coming back to your function until they end up in the dead-letter queue (DLQ) together with the broken one.

The fix is a division of labour. You check the envelope once with SqsSchema, because if that is wrong, something is fundamentally broken anyway. After that you check every message on its own with safeParse. You report invalid messages through the partial batch response batchItemFailures. Lambda then deletes only the successful messages from the queue.

Step 4: Write the queue handler

// src/queue-handler.ts
import { SqsSchema } from '@aws-lambda-powertools/parser/schemas/sqs';
import { JSONStringified } from '@aws-lambda-powertools/parser/helpers';
import type { SQSBatchResponse } from 'aws-lambda';
import { RentalRequestSchema, type RentalRequest } from './rental-request.js';

const RentalMessage = JSONStringified(RentalRequestSchema);

export async function reserveBike(rental: RentalRequest): Promise<void> {
  // Placeholder for the real work, e.g. a DynamoDB write
  console.log(JSON.stringify({ msg: 'reserved', bikeId: rental.bikeId }));
}

export const handler = async (event: unknown): Promise<SQSBatchResponse> => {
  // 1. Check the envelope: is this an SQS event at all?
  const { Records } = SqsSchema.parse(event);
  const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];

  // 2. Check every message on its own so one broken message does not block the whole batch
  for (const record of Records) {
    const result = RentalMessage.safeParse(record.body);
    if (!result.success) {
      console.warn(
        JSON.stringify({
          msg: 'invalid message',
          messageId: record.messageId,
          issues: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
        }),
      );
      batchItemFailures.push({ itemIdentifier: record.messageId });
      continue;
    }
    try {
      await reserveBike(result.data);
    } catch {
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
};
Enter fullscreen mode Exit fullscreen mode

When deciding what happens to an invalid message, there are two defensible options. Either you drop it right away, write a log entry and count a metric. Or you report it as failed so it lands in the DLQ after a few attempts. I usually go for the second option, even though retries do not change anything for a validation error. My reason: in the DLQ I can look at the message later, fix it and replay it. A dropped log line, by contrast, is quickly forgotten.

FIFO queues follow a special rule, by the way. There you stop after the first failure and report that message and all following ones as failed, otherwise the order gets mixed up. The code above is meant for standard queues.

🎬 Flow: SQS batch with one invalid message – this animated diagram only runs on my site: watch it on sascha-ms.de

Deployment with the Serverless Framework

Step 5: Create serverless.yml

For the infrastructure I use the Serverless Framework this time. Version 4 bundles TypeScript on its own with esbuild, so you do not need an extra plugin. The key line is functionResponseType: ReportBatchItemFailures on the SQS event. Without it, Lambda ignores your batchItemFailures and treats every return value as success.

# serverless.yml
service: bike-rental-validation
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs24.x
  region: eu-central-1
  architecture: arm64
  memorySize: 512
  logRetentionInDays: 14

build:
  esbuild:
    bundle: true
    minify: true
    sourcemap: true

functions:
  createRental:
    handler: src/http-handler.handler
    events:
      - httpApi:
          method: POST
          path: /rentals

  reserveBike:
    handler: src/queue-handler.handler
    timeout: 30
    events:
      - sqs:
          arn: !GetAtt RentalQueue.Arn
          batchSize: 10
          functionResponseType: ReportBatchItemFailures

resources:
  Resources:
    RentalQueue:
      Type: AWS::SQS::Queue
      Properties:
        VisibilityTimeout: 180
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt RentalDlq.Arn
          maxReceiveCount: 5
    RentalDlq:
      Type: AWS::SQS::Queue
      Properties:
        MessageRetentionPeriod: 1209600
Enter fullscreen mode Exit fullscreen mode

Two values come straight from the AWS recommendations. The queue visibility timeout is six times the function timeout, so 180 seconds for a 30-second timeout. And maxReceiveCount is set to 5, so a message gets a few attempts before it moves to the DLQ.

Step 6: Deploy and try it out

npx serverless deploy

curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/rentals" \
  -H "content-type: application/json" \
  -d '{"bikeId":"BK-0042","customerEmail":"kim@example.org","from":"2026-11-07T09:00:00+01:00","until":"2026-11-07T17:00:00+01:00"}'

curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/rentals" \
  -H "content-type: application/json" \
  -d '{"bikeId":"42"}'
Enter fullscreen mode Exit fullscreen mode

The first request returns {"status":"accepted","bikeId":"BK-0042","helmet":false}. The default value for helmet comes from the schema, by the way. The second request gets a 400 listing every missing field. To see how the queue handler behaves, send a broken message with aws sqs send-message and watch it show up in the DLQ after five attempts.

Tests: validate Lambda events without an AWS account

A nice side effect of this split is that you can test everything locally. I use the built-in Node.js test runner so no further dependency sneaks in. The events are built by hand and reduced to the fields the schemas require.

// test/handlers.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Context } from 'aws-lambda';
import { handler as httpHandler } from '../src/http-handler.js';
import { handler as queueHandler } from '../src/queue-handler.js';

const valid = {
  bikeId: 'BK-0042',
  customerEmail: 'kim@example.org',
  from: '2026-11-07T09:00:00+01:00',
  until: '2026-11-07T17:00:00+01:00',
};

const httpEvent = (body: string) => ({
  version: '2.0',
  routeKey: 'POST /rentals',
  rawPath: '/rentals',
  rawQueryString: '',
  headers: { 'content-type': 'application/json' },
  requestContext: {
    accountId: '123456789012',
    apiId: 'abc123',
    domainName: 'abc123.execute-api.eu-central-1.amazonaws.com',
    domainPrefix: 'abc123',
    http: { method: 'POST', path: '/rentals', protocol: 'HTTP/1.1', sourceIp: '203.0.113.10', userAgent: 'test' },
    requestId: 'req-1',
    routeKey: 'POST /rentals',
    stage: '$default',
    time: '07/Nov/2026:09:00:00 +0000',
    timeEpoch: 1793955600000,
  },
  body,
  isBase64Encoded: false,
});

const sqsRecord = (messageId: string, body: string) => ({
  messageId,
  receiptHandle: 'handle-' + messageId,
  body,
  attributes: {
    ApproximateReceiveCount: '1',
    SentTimestamp: '1793955600000',
    SenderId: 'AIDAEXAMPLE',
    ApproximateFirstReceiveTimestamp: '1793955600001',
  },
  messageAttributes: {},
  md5OfBody: 'd41d8cd98f00b204e9800998ecf8427e',
  eventSource: 'aws:sqs',
  eventSourceARN: 'arn:aws:sqs:eu-central-1:123456789012:rental-requests',
  awsRegion: 'eu-central-1',
});

const ctx = {} as Context;

test('HTTP: valid request returns 202 and applies the default', async () => {
  const res = (await httpHandler(httpEvent(JSON.stringify(valid)) as never, ctx)) as { statusCode: number; body: string };
  assert.equal(res.statusCode, 202);
  assert.equal(JSON.parse(res.body).helmet, false);
});

test('HTTP: wrong fields return 400 with field names', async () => {
  const bad = { ...valid, bikeId: '42', until: '2026-11-07T08:00:00+01:00' };
  const res = (await httpHandler(httpEvent(JSON.stringify(bad)) as never, ctx)) as { statusCode: number; body: string };
  assert.equal(res.statusCode, 400);
  const fields = JSON.parse(res.body).issues.map((i: { field: string }) => i.field);
  assert.ok(fields.includes('bikeId'));
});

test('HTTP: broken JSON returns 400', async () => {
  const res = (await httpHandler(httpEvent('{broken') as never, ctx)) as { statusCode: number };
  assert.equal(res.statusCode, 400);
});

test('SQS: only the invalid message is reported as failed', async () => {
  const event = {
    Records: [
      sqsRecord('m-1', JSON.stringify(valid)),
      sqsRecord('m-2', JSON.stringify({ ...valid, customerEmail: 'not-an-email' })),
      sqsRecord('m-3', JSON.stringify({ ...valid, bikeId: 'BK-0007' })),
    ],
  };
  const res = await queueHandler(event);
  assert.deepEqual(res.batchItemFailures, [{ itemIdentifier: 'm-2' }]);
});

test('SQS: an event without Records is rejected entirely', async () => {
  await assert.rejects(() => queueHandler({ foo: 'bar' }));
});
Enter fullscreen mode Exit fullscreen mode
npm pkg set scripts.test="tsc && node --test dist/test/*.test.js"
npm test
Enter fullscreen mode Exit fullscreen mode

You should see five green tests. The most important one is the fourth: out of three messages, only m-2 is reported as failed. This very test protects you from someone later parsing the whole batch at once again for convenience.

Common mistakes and how to avoid them

  • Schema without JSONStringified: the body is a string, your schema expects an object. Every request fails with a confusing type error.
  • Forgetting ReportBatchItemFailures: the function dutifully returns failures, but nobody listens. Invalid messages simply disappear. A typical warning sign: the log shows validation errors, yet the DLQ stays empty.
  • Throwing for the whole batch: an unhandled exception in the loop makes all messages visible again. That is why the actual work sits in a try block per message.
  • Too much in the error text: return field names and rules in the 400 response, but no internal details such as stack traces or table names.
  • Maintaining the schema twice: if frontend and backend are both written in TypeScript, you can put the Zod schema into a shared package. Then both sides check with the same rules.

Cost and security

Validation itself adds no extra AWS charges; it runs inside your function. Zod and the Parser do increase your bundle size, though, which can show up during cold starts. Measure this in your project instead of guessing, and let esbuild minify as in the serverless.yml above. From a security point of view, strict input validation is one of the cheapest measures there is. It does not replace authentication or checking webhook signatures, though.

Still unsure whether a queue or a direct call fits your case better? The AWS service finder helps you choose between SQS, EventBridge and friends.

My take

I consider validation at the system boundary one of those habits you regret fastest in Serverless projects when it is missing. The Powertools Parser takes the boring work off your plate, above all rebuilding the AWS event structures. What it does not take off your plate is thinking about error behaviour. Especially for the SQS envelope, I find the documentation a bit too optimistic: going through the envelope is convenient, but it only fits when an invalid batch really may fail as a whole.

Where I disagree: some teams validate again in every layer, “better safe than sorry”. That costs readability and creates contradicting rules. One check at the entrance, then trust in the types, is the better deal for me.

FAQ

Does the Powertools Parser still work with Zod 3?

The current version 2.36 of @aws-lambda-powertools/parser lists Zod 4 as a peer dependency. For your own schemas, the Parser also accepts other libraries that implement the Standard Schema spec, such as Valibot. The bundled AWS schemas, however, are Zod schemas.

Do I need Middy to use the Parser?

No. Middy is just one of three options. You can also call schemas and envelopes directly with parse or safeParse, as in the queue handler above, or use a decorator on a class method.

How do I validate EventBridge events?

The easiest way is the EventBridgeEnvelope, which checks only the detail field against your schema. If you also need metadata such as source or time, extend EventBridgeSchema with .extend({ detail: yourSchema }) instead.

Should I drop invalid SQS messages or send them to the DLQ?

Both are defensible. Dropping saves retries; the DLQ keeps the message for later analysis and replay. I prefer the DLQ with an alarm on its length, so nobody overlooks the failures.

If you want to test handlers like these with Vitest and mocked SDK clients, without an AWS account, see Lambda unit tests with Vitest.

Your next step

Pick the Lambda function in your project that receives the most data from outside and write a Zod schema for its payload. Start with three fields that would really hurt if they were missing. And I am curious: do you already validate at the entrance, or do you still rely on the types from @types/aws-lambda?

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.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

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)