Inbox
articleJune 9, 20266 min read

AWS Lambda Send Email: SES Patterns That Scale

Patterns for sending transactional email from AWS Lambda with SES: handler structure, cold-start tradeoffs, IAM scoping, retries, and SQS-based spike absorption.

By SESMetric Editorial

Lambda gives you a per-request runtime that scales to zero, which is exactly the cost shape transactional email needs. Most password resets, receipts, and verification emails fire in unpredictable bursts and stay idle the rest of the day. Pair Lambda with Amazon SES and you get a send pipeline that costs cents at low volume and absorbs spikes without a fleet to manage — if you wire it carefully.

This guide covers the patterns that matter when you send email from Lambda: the SDK call itself, cold-start tradeoffs, IAM scoping, throttling, and how SQS in front of your function turns a brittle one-shot send into a replayable queue.

Why AWS Lambda is a good fit for transactional email

For an aws lambda send email flow, the function is the smallest piece of the system: receive an event, call SendEmail, return. There is no server to keep warm for the 99% of the day when nobody is signing up. Lambda also gives you per-invoke concurrency you can cap, which matters because SES will throttle you the moment you exceed your account's send rate.

The constraints you have to design around are:

  • SES enforces a per-second send rate and a 24-hour quota.
  • Lambda cold starts add latency to the first invoke after idle.
  • The handler must be idempotent or your retries will double-send.

Address those three and the rest is plumbing.

A minimal Python handler with SESv2

Initialize the boto3 client outside the handler. The Lambda runtime reuses the execution context across warm invocations, so the client (and its TLS handshakes and credentials chain) is paid for once per container.

import json
import logging
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

log = logging.getLogger()
log.setLevel(logging.INFO)

# Reuse across warm invocations. Tight timeouts so retries are fast.
SES = boto3.client(
    "sesv2",
    config=Config(
        connect_timeout=2,
        read_timeout=5,
        retries={"max_attempts": 3, "mode": "standard"},
    ),
)

FROM_ADDR = os.environ["FROM_ADDR"]
CONFIG_SET = os.environ.get("SES_CONFIG_SET")


def handler(event, context):
    to_addr = event["to"]
    subject = event["subject"]
    html = event["html"]
    text = event.get("text", "")

    try:
        resp = SES.send_email(
            FromEmailAddress=FROM_ADDR,
            Destination={"ToAddresses": [to_addr]},
            Content={
                "Simple": {
                    "Subject": {"Data": subject, "Charset": "UTF-8"},
                    "Body": {
                        "Html": {"Data": html, "Charset": "UTF-8"},
                        "Text": {"Data": text, "Charset": "UTF-8"},
                    },
                }
            },
            ConfigurationSetName=CONFIG_SET,
        )
    except ClientError as e:
        code = e.response["Error"]["Code"]
        log.error(json.dumps({
            "event": "ses_send_failed",
            "error_code": code,
            "request_id": context.aws_request_id,
            "to_hash": hash(to_addr),
        }))
        # Re-raise so SQS/Lambda retry policy or DLQ takes over.
        raise

    log.info(json.dumps({
        "event": "ses_sent",
        "message_id": resp["MessageId"],
        "request_id": context.aws_request_id,
        "to_hash": hash(to_addr),
    }))
    return {"message_id": resp["MessageId"]}

Notes on the choices:

  • sesv2 is the current API. The v1 ses client still works, but v2 has cleaner request shapes and is what AWS adds new features to.
  • Reading PII like the full recipient into logs is a compliance footgun. Hash it (or log only the domain) so failures stay replayable without leaking addresses.
  • Re-raise on failure. Swallowing the exception will mark a queued message as successfully processed and you will lose it.

Cold-start latency and provisioned concurrency

A cold Python 3.12 container that imports boto3 and instantiates the SES client typically adds 300–700 ms before your handler body runs. For an interactive send — a user hitting "reset password" and waiting on the next screen — that latency is noticeable.

You have two levers:

  • Provisioned concurrency. Pin N execution environments warm. Cold starts disappear for the first N concurrent invokes. You pay for the warm capacity whether or not it is used.
  • Init-time work. Move every import and client construction to module scope (as in the handler above), and trim dependencies you do not need. The smaller the deployment package, the shorter the init phase.

Provisioned concurrency pays off when your traffic has a daily floor — say 5 concurrent invokes you can count on. For pure spike traffic (zero baseline, sudden burst), you mostly pay for capacity you do not use. Measure with CloudWatch's Init Duration metric before turning it on.

A practical middle path: leave PC off, keep the deployment slim, and accept that the first send after a long idle is a half-second slower than the rest.

Staying inside SES sending limits

This is where most Lambda-to-SES integrations break in production.

  • A brand-new SES account is in sandbox mode: 200 emails/24h and 1 send per second. You can only send to verified addresses.
  • Once you graduate to production mode (request via the SES console), the defaults usually start at 50,000/24h and 14/sec. AWS raises both as your reputation grows.
  • The rate limit is account-level, not per-function. Spinning up a second Lambda does not get you a second 14/sec budget.

If your Lambda runs unbounded concurrency and 200 password reset requests land in the same second, SES will accept the first ~14 and reject the rest with ThrottlingException. Cap the function's reserved concurrency to match (or undershoot) your SES rate. Reserved concurrency of 10 on a 14/sec quota leaves headroom for other systems on the same account.

Also watch for SendingPausedException. AWS pauses sending on an account when bounce or complaint rates cross a threshold. Treat it as non-retryable from the function — the queue should hold the message until a human investigates.

IAM scope: grant only ses:SendEmail

The default temptation is ses:*. Do not. The function only needs to send, so the policy is one action against the identities you have verified.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SendOnly",
      "Effect": "Allow",
      "Action": "ses:SendEmail",
      "Resource": [
        "arn:aws:ses:us-east-1:123456789012:identity/example.com",
        "arn:aws:ses:us-east-1:123456789012:configuration-set/transactional"
      ]
    }
  ]
}

The configuration set ARN is required if you reference one in the handler (the snippet above does). Scoping to the identity prevents a compromised function from sending as a domain that is not yours, and scoping to the configuration set prevents bypassing event publishing (bounces, complaints, opens) by sending without it.

If you also use SendBulkEmail for templated batches, add that action explicitly — ses:SendEmail does not cover it.

Retries and structured logging

ThrottlingException is the one error you must retry. It means SES is healthy and willing to send; you just exceeded your per-second rate. The botocore standard retry mode handles a few in-process, but if your burst outlasts the SDK's backoff window the call still fails out to your handler.

Two things to set up:

  1. Lambda destinations or DLQ. Configure an on-failure destination (SQS or SNS) for asynchronous invocations, or a DLQ on the upstream SQS queue (next section). Failed sends land somewhere replayable, never just in CloudWatch logs.
  2. Structured JSON logs. One log line per outcome (ses_sent, ses_send_failed), with message_id, request_id, hashed recipient, and error_code. Query them with CloudWatch Logs Insights when a customer reports a missing email.

A quick local invoke to sanity-check the handler before deploying:

aws lambda invoke \
  --function-name send-transactional-email \
  --payload '{"to":"test@example.com","subject":"Hi","html":"<p>Hi</p>"}' \
  --cli-binary-format raw-in-base64-out \
  response.json

If the response has a MessageId, the IAM scope, environment variables, and SES verification all line up.

Spike absorption with SQS

A Lambda invoked directly from an API call has no buffer. If 5,000 sign-ups hit in a minute, you either burn through SES throttling or scale Lambda concurrency past your SES quota and start dropping. Neither is acceptable for transactional mail.

Put SQS in front. The pattern:

  1. Your API enqueues a JSON message per send — recipient, subject, template variables.
  2. An SQS event source mapping invokes the Lambda. Batch size 1 for simplicity, higher if your handler iterates inside.
  3. Set the function's reserved concurrency to a number at or below your SES per-second rate.
  4. Configure the SQS queue's redrive policy to a DLQ after 3–5 receives.

The queue smooths the burst. Lambda scales up to (but no further than) your concurrency cap, which keeps SES inside its rate. Failed messages — bad template, bounce-paused account, transient error — go to the DLQ and you decide whether to replay them.

This is the same shape SESMetric uses internally: a queue absorbs the spike, a capped pool of workers drains it at SES's pace, and every failure is replayable from durable storage. The Lambda function stays small; the durability lives in SQS. That separation is what makes an aws lambda send email pipeline reliable enough to put password resets on.

Tagsaws-lambdaaws-sesserverlesstransactional-email