skip to content

In Amazon SES, when would you use SendBulkEmail with a stored email template instead of calling SendEmail in a loop, and what does the bulk response tell you?

level: middleimportance: nice to knowfreq 33%

answer

  1. one template, many destinations
  2. saves round trips, not quota
  3. each destination is still a message
  4. success does not mean every entry sent
  5. check the per-entry status list

basics

~20 s

Use SendBulkEmail when one templated message goes to many recipients with per-recipient substitutions: one call covers a batch of destinations instead of one call each. Its response reports a status per destination, so a successful call can still contain individually failed entries.

solid answer

~50 s

`SendBulkEmail` pairs a stored template with a list of destinations, each carrying its own substitution data, so a batch of personalised messages costs one API round trip instead of one per recipient. That is its real benefit: fewer round trips and centrally managed content, since the template's subject, HTML and text parts live in SES and are edited with `CreateEmailTemplate` rather than being redeployed with the application. What it does **not** buy you is quota relief — every destination counts as a separate message against your sending quota. The response is the part candidates miss: it returns a per-entry result with a status and message ID for each destination, so an HTTP success can still hide individually rejected entries. Code that checks only for an exception silently drops those recipients. Reach for plain `SendEmail` when each message is genuinely different, when you need an attachment or hand-built MIME, or when the mail is a one-off transactional send.

code

python · 20 lines
python
import json, boto3

ses = boto3.client("sesv2")
resp = ses.send_bulk_email(
    FromEmailAddress="[email protected]",
    DefaultContent={"Template": {
        "TemplateName": "order-shipped",
        "TemplateData": json.dumps({"name": "there"}),
    }},
    BulkEmailEntries=[
        {"Destination": {"ToAddresses": ["[email protected]"]},
         "ReplacementEmailContent": {"ReplacementTemplate": {
             "ReplacementTemplateData": json.dumps({"name": "Ada"})}}},
        {"Destination": {"ToAddresses": ["[email protected]"]}},
    ],
)

# A successful call can still contain failed entries - always inspect them
for result in resp["BulkEmailEntryResults"]:
    print(result["Status"], result.get("MessageId"), result.get("Error"))

go deeper

for a junior

Know that SES can store an email template with placeholders and send it to a batch of recipients in one call, each with their own substitution values, instead of one call per person.

for a middle

Explain what bulk sending buys (fewer round trips, content managed outside the deployment) and what it does not (no quota relief), and that the response carries a status per destination that you must inspect.

for a senior

Show that you treat shared templates as an interface — versioned, tested against every caller's payload — and that you persist each entry's message ID so later delivery and complaint events correlate back to a recipient.

for a principal

Own the boundary between engineering and the people who write the mail: where template content lives, who may change it, how changes are reviewed, and how campaign sending is isolated from transactional sending.

## What the bulk operation actually is `SendBulkEmail` in the SESv2 API takes: - **one template** as the default content — `TemplateName` plus default `TemplateData`; - **a list of bulk email entries**, each with its own `Destination` and, optionally, replacement template data for that recipient. SES renders the template once per entry with that entry's data merged over the defaults. The template itself is a stored resource created with `CreateEmailTemplate`, holding a subject line, an HTML body and a text body, with `{{placeholder}}` substitutions. The number of destinations in one call is bounded (50 as of 2025), so "bulk" means batching, not unlimited fan-out. A campaign still loops — it just loops over batches. ## What you gain **Fewer round trips.** One HTTPS request and one signature for a batch instead of one per recipient. At scale that is a real reduction in latency and connection churn in the sending worker. **Content lives outside the deployment.** The template is a stored SES resource. Marketing or support can change wording without a code release, and the same template can be reused by several services. That is often the stronger argument in practice. **Consistent HTML and text parts.** The template carries both, so every send has a plain-text alternative without each caller remembering to build one. ## What you do not gain **Quota relief.** Every destination is a separate message against your 24-hour budget. Bulk sending saves API calls, not quota. Candidates who claim a bulk call "counts as one" against the sending limit are wrong in a way that would wreck a capacity plan. **Arbitrary content.** Bulk sending is template-only. If a recipient needs an attachment, a unique MIME structure, or content assembled entirely in code, that is a `SendEmail` call — with the raw content variant when you are building the MIME yourself. ## The response is a per-entry result This is the interview-grade detail. The call returns a list of results, one per entry, each with a **status**, an optional **error**, and a **message ID** when the entry was accepted. A call can return successfully while several entries inside it were rejected — a suppressed address, a rendering failure because required substitution data was missing, an account-level problem. So the correct client shape is: ```python resp = ses.send_bulk_email(...) for entry, result in zip(entries, resp["BulkEmailEntryResults"]): if result["Status"] != "SUCCESS": record_failure(entry, result["Status"], result.get("Error")) else: record_sent(entry, result["MessageId"]) ``` Code that wraps the call in a `try` and assumes no exception means 50 sends will quietly drop recipients, and the loss is invisible until someone asks why a segment never received the mail. The results arrive in the same order as the entries you submitted, which is what lets you correlate them back. **Rendering failures deserve a specific mention.** If the template references a placeholder that an entry's data does not supply, that entry can fail to render. This is the failure mode unique to templated sending, and it is why template changes need testing against real substitution payloads: adding `{{first_name}}` to a template can break every caller that does not send it. ## Message IDs are the join key Each accepted entry gets its own message ID. That ID is how you later correlate a delivery, bounce or complaint event back to the specific recipient and campaign. Discarding the per-entry results throws away that correlation, so store them with the recipient record. ## Choosing, in practice - **Same message, many recipients, per-recipient fields** → `SendBulkEmail` with a template. - **One recipient, transactional, content built in code** → `SendEmail`. - **Attachments or hand-built MIME** → `SendEmail` with raw content. - **Content that non-engineers must edit** → a stored template, regardless of which send operation you use, since single sends can reference templates too.

  • Does a SendBulkEmail call with 50 destinations count as one message against the sending quota?
    No — each destination is a separate message against both the 24-hour budget and the send rate. Bulk sending saves API round trips and centralises content; it gives no quota relief whatsoever. Believing otherwise produces capacity plans that are off by the batch size, which is exactly the kind of error that surfaces mid-campaign.
  • A bulk call returns without error but a handful of recipients never got the mail. Where do you look?
    The per-entry results in the response. Each entry has its own status and optional error, and a call can succeed overall while individual entries were rejected — a suppressed address, or a rendering failure because the entry's substitution data lacked a placeholder the template requires. Log the per-entry status and message ID for every entry rather than only catching exceptions.
  • What breaks when someone adds a new placeholder to a shared template?
    Any caller that does not supply that field can hit a rendering failure for its entries, and only those entries fail — the rest of the batch goes out. Because templates are shared resources edited outside the deployment, treat a template change like an interface change: test it against every caller's real substitution payload before publishing.

saying these in an interview costs you the question

  • Thinks a bulk call counts as one message for quota
  • Only catches exceptions, ignoring per-entry results
  • Expects to attach files through bulk templated sends
  • Assumes unlimited destinations in one bulk call
  • Edits shared templates without testing caller payloads

context