Send an Excel Report with the SendGrid API
SMTP works for sending reports, but many organisations route automated email through a delivery service so they get bounce handling, suppression lists, sender authentication and a log of every message. Twilio SendGrid is one of the most common, and its Web API is often easier to use from a scheduled job than SMTP: a single HTTPS request with an API key, no ports to open, and a clear JSON error when something is wrong. This page sends an Excel report with the official sendgrid Python library — HTML and text bodies, the workbook as an attachment, one personalised message per recipient, an optional dynamic template — and covers testing without sending, error handling and the size limits. It belongs to Emailing Excel Reports with smtplib.
Prerequisites
pip install sendgrid pandas openpyxl
You need a SendGrid account, a verified sender (a single sender address or an authenticated domain) and an API key with the Mail Send permission. Store the key in the SENDGRID_API_KEY environment variable rather than in the script — see Store Credentials for Excel Report Scripts Safely. sales_report.xlsx is the report to send. The outputs on this page were produced against a local stand-in for the API, so no real email was sent; the requests are the same.
SMTP relay or Web API?
SendGrid also offers an SMTP relay, so an existing smtplib script can switch to it by changing the host, port and credentials (the username is the literal string apikey and the password is the API key). That is the quickest migration. The Web API is the better long-term choice for a scheduled job: it runs over HTTPS on port 443, which corporate firewalls rarely block, it validates the whole message in one request and answers with a specific error, and it supports personalizations, templates and sandbox mode directly.
Step 1 — Build the message and attach the workbook
A Mail object holds sender, recipients, subject and bodies. The workbook goes in as an Attachment whose content is base64-encoded text:
import base64
import os
from pathlib import Path
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Attachment, Disposition, FileContent, FileName, FileType, Mail
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def excel_attachment(path):
data = base64.b64encode(Path(path).read_bytes()).decode("ascii")
return Attachment(FileContent(data), FileName(Path(path).name), FileType(XLSX), Disposition("attachment"))
message = Mail(
from_email=("reports@example.com", "Reporting"),
to_emails="finance@example.com",
subject="Sales report — September 2026",
plain_text_content="The September sales report is attached. Total revenue: 40,300.",
html_content="<p>The September sales report is attached.</p><p><b>Total revenue: 40,300</b></p>",
)
message.attachment = excel_attachment("sales_report.xlsx")
payload = message.get()
print(payload["subject"], "|", payload["attachments"][0]["filename"], "|", payload["attachments"][0]["type"][:40] + "…")
from_email accepts a tuple of address and display name. Giving both a plain-text and an HTML body lets every mail client show something sensible. The MIME type tells the recipient's client to open the attachment with Excel; using application/octet-stream instead works, but some clients then show a generic file icon or warn about it. message.get() returns the JSON body the library will send — useful for checking the message before anything leaves.
Step 2 — Send it and read the response
SendGridAPIClient.send() posts the message to the /v3/mail/send endpoint:
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
response = sg.send(message)
print(response.status_code, response.headers.get("X-Message-Id"))
A status of 202 Accepted means SendGrid has queued the message for delivery — not that it has been delivered. The X-Message-Id header identifies the message in SendGrid's activity feed and event webhooks, so log it alongside the report run; if a recipient says they never received the report, that ID is how you find out whether it bounced, was blocked or went to spam. For EU accounts with data residency, call sg.set_sendgrid_data_residency("eu") before sending so requests go to the EU endpoint.
Step 3 — One personalised email per recipient
Sending to several people with one to_emails list puts everyone in the same To: header, so recipients see each other's addresses. Personalizations send a separate copy to each recipient in a single API call, each with its own substitutions:
from sendgrid.helpers.mail import Personalization, Substitution, To
managers = [
{"email": "north@example.com", "name": "Ana", "region": "North", "revenue": 18400},
{"email": "south@example.com", "name": "Ben", "region": "South", "revenue": 12100},
]
message = Mail(from_email=("reports@example.com", "Reporting"), subject="Your region: September 2026",
html_content="<p>Hi -name-,</p><p>-region- revenue for September: <b>-revenue-</b>.</p>")
for m in managers:
p = Personalization()
p.add_to(To(m["email"], m["name"]))
p.add_substitution(Substitution("-name-", m["name"]))
p.add_substitution(Substitution("-region-", m["region"]))
p.add_substitution(Substitution("-revenue-", f"{m['revenue']:,}"))
message.add_personalization(p)
message.attachment = excel_attachment("sales_report.xlsx")
body = message.get()
print(len(body["personalizations"]), "copies:", [p["to"][0]["email"] for p in body["personalizations"]])
print(body["personalizations"][0]["substitutions"])
Each personalization becomes its own email: Ana sees only her address and "North revenue for September: 18,400". Up to 1,000 personalizations fit in one request. (The library inserts each new personalization at the front of the list, which is why South is listed first; the order makes no difference to delivery.) The attachment is shared by all copies; if each manager should get their own workbook, send one message per manager instead, as in Send Each Recipient Their Own Excel Report.
Step 4 — Use a dynamic template
For a report email whose layout marketing or finance want to edit without touching code, create a dynamic template in the SendGrid dashboard using Handlebars placeholders such as {{region}} and {{revenue}}, then send only the data:
message = Mail(from_email=("reports@example.com", "Reporting"), to_emails="north@example.com")
message.template_id = "d-0123456789abcdef0123456789abcdef"
message.dynamic_template_data = {"name": "Ana", "region": "North", "revenue": "18,400", "period": "September 2026"}
message.attachment = excel_attachment("sales_report.xlsx")
body = message.get()
print(body["template_id"], "|", body["personalizations"][0]["dynamic_template_data"]["region"])
The template ID starts with d- and is copied from the dashboard. The subject can be part of the template too. Dynamic template data is per personalization, so the per-recipient pattern from Step 3 works the same way with p.dynamic_template_data = {...}. Keep numbers formatted in Python — Handlebars in SendGrid templates has only basic helpers.
Step 5 — Test without sending, and handle errors
Sandbox mode makes SendGrid validate the request completely — authentication, addresses, attachment encoding — and return 200 without delivering anything. Turn it on in tests and in a dry-run flag:
from python_http_client.exceptions import HTTPError
from sendgrid.helpers.mail import MailSettings, SandBoxMode
def send_report(message, dry_run=False, api_key=None):
if dry_run:
message.mail_settings = MailSettings(sandbox_mode=SandBoxMode(True))
try:
resp = SendGridAPIClient(api_key or os.environ["SENDGRID_API_KEY"]).send(message)
return resp.status_code, resp.headers.get("X-Message-Id")
except HTTPError as err:
details = [e["message"] for e in err.to_dict.get("errors", [])]
raise RuntimeError(f"SendGrid rejected the report email ({err.status_code}): {details}") from err
msg = Mail(from_email="reports@example.com", to_emails="finance@example.com",
subject="Dry run", plain_text_content="Test")
print("sandbox:", send_report(msg, dry_run=True))
try:
send_report(Mail(from_email="reports@example.com", to_emails="finance@example.com",
subject="x", plain_text_content="x"), api_key="SG.revoked")
except RuntimeError as e:
print(e)
The library raises HTTPError for any 4xx or 5xx response; err.to_dict holds SendGrid's JSON error list, which says exactly what is wrong — a revoked key, an unverified sender, a malformed address. Re-raising with that message makes the failure readable in the job log. Retry only on 429 (rate limited) and 5xx responses; a 400 or 401 will fail the same way every time. The general retry pattern is in Retry a Failed Excel Report Job in Python.
Size limits
SendGrid accepts messages up to 30 MB in total, including all attachments after base64 encoding, which inflates files by about a third — so a workbook much over 20 MB will not fit, and many receiving servers reject far smaller messages. Check the size before building the message:
size_mb = Path("sales_report.xlsx").stat().st_size * 4 / 3 / 1024 / 1024
print(f"encoded attachment: {size_mb:.3f} MB of 30 MB")
For large reports, zip the workbook — see Zip Large Excel Attachments Before Emailing — or upload it to cloud storage and email a link.
Common pitfalls
- Treating 202 as delivered. It means queued; check events or the activity feed for delivery.
- All recipients in one
to_emailslist. Everyone sees everyone; use personalizations. - Unverified sender. The API returns 403 until the from address or domain is verified.
- API key in the script. Read it from the environment or a secret store.
- Retrying 4xx errors. They will not succeed on retry; fix the request.
Conclusion
The sendgrid library turns sending an Excel report into building a Mail, attaching the workbook as base64 with the xlsx MIME type, and calling send(). Use personalizations for per-recipient copies, a dynamic template when others own the email layout, sandbox mode for dry runs and tests, and HTTPError.to_dict for readable failures. Log the message ID from every 202 response, and keep attachments well under the 30 MB limit.
Frequently asked questions
How do I attach an Excel file with the SendGrid Python library? Base64-encode the file bytes, then create an Attachment with FileContent, FileName, FileType set to the xlsx MIME type and Disposition attachment, and assign it to message.attachment.
What does a 202 response from SendGrid mean? The message was accepted and queued for delivery. It does not confirm delivery; use the X-Message-Id header to look the message up in the activity feed or event webhooks.
How do I test SendGrid emails without sending them? Enable sandbox mode in the mail settings. SendGrid validates the whole request and returns 200 without delivering the email.
How do I send the same report to several people privately? Add one Personalization per recipient to the Mail. Each becomes a separate email with only that recipient in the To header.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports by email.
- Send an Excel Report with Amazon SES — another delivery service.
- Send an Excel Report with the Microsoft Graph API — sending from a Microsoft 365 mailbox.
- Embed a Chart Image in the Body of a Report Email — a chart inside the message.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email