sendgrid
Twilio SendGrid's official Python client for the SendGrid Web API v3. Two things live in the package: a set of Mail helper classes that assemble the JSON body for the /mail/send endpoint, and a fluent client where sg.client.suppression.bounces.get() maps directly onto the REST path. It also ships an EventWebhook helper for verifying signed webhook callbacks. It is an HTTP wrapper, not an SMTP library and not a mail server: every object here exists to produce SendGrid-shaped JSON, and everything about deliverability stays in the SendGrid console.
If your mail already goes through SendGrid, this is the path of least resistance and the helper classes save real boilerplate on /mail/send. Pick it knowing it is a slow-moving vendor wrapper that locks your send path to one provider, and reach for django-anymail if that lock-in matters.
Use it if
- You already send through Twilio SendGrid and want the /mail/send request body assembled by helper classes instead of hand-written dictionaries
- You need the rest of the v3 API from Python (suppressions, templates, subusers, stats, API keys) through one client that mirrors the REST paths
- You have to verify Event Webhook callbacks in Python, which the EventWebhook helper does with the ECDSA public key from Mail Settings
- You need EU data residency, which set_sendgrid_data_residency('eu') switches on by repointing the client at api.eu.sendgrid.com
- You want to stay provider-neutral: every class here maps one-to-one onto SendGrid JSON, so changing providers later means rewriting the send path rather than swapping a backend. django-anymail exists for exactly this reason
- You expect an actively developed client: 6.12.5 was released on 2025-09-19 and the repository was last pushed on 2026-06-25, with 65 open issues and pull requests. It is vendor-maintained at a slow cadence, not abandoned, but do not file a bug expecting a fast turnaround
- You need async: the transport is python-http-client, which is a thin synchronous wrapper over urllib. Under asyncio you must push every send into a thread yourself
- You want readable errors: a failed call raises a python_http_client exception whose useful detail sits in e.body as raw bytes, so every caller writes the same json.loads(e.body) boilerplate
- You read package metadata as a health signal: requires_python still starts at 2.7, the dependency list still carries conditional Werkzeug pins for Python versions that reached end of life years ago, and there is no Development Status classifier at all
- You are hoping the library improves delivery rates. It does not. Domain authentication, dedicated IPs, warmup and suppression hygiene are all console and DNS work
Setup reality
pip install sendgrid pulls python-http-client and cryptography, so there is a compiled dependency in the tree even though you are only making HTTP calls. Before a single send works you have to create an API key in the console and authenticate either a single sender or a whole domain through DNS; until that is done every request comes back 403 no matter how correct your code is. Put the key in SENDGRID_API_KEY, because the constructor reads nothing by default and passing it inline is how keys end up in git (the package even ships a ValidateApiKey helper for pre-commit checks). The Mail helper is more permissive than it looks: to_emails accepts a string, a tuple, a To object, or a list of any of those, and the is_multiple flag is what decides whether recipients see each other, so getting it wrong leaks addresses. Responses are python_http_client Response objects with status_code, headers, and a body that is bytes, not a parsed dict, and a 202 means SendGrid accepted the message for processing, not that anyone received it. Failures raise instead of returning, and the readable error text is inside e.body. Dynamic templates are an either-or: once you set template_id and dynamic_template_data, any Content you attached is ignored, which is a common silent surprise. Testing against production is avoidable with SandBoxMode in mail_settings, which validates the payload without sending. If you need EU residency, call set_sendgrid_data_residency('eu') before the first send, since it rebuilds the underlying client.
Patterns
Send a plain text emailsend-basic-email
import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
message = Mail(
from_email='alerts@yourdomain.com',
to_emails='user@example.com',
subject='Deploy finished',
plain_text_content='Build 412 is live.',
)
sg = SendGridAPIClient(os.environ['SENDGRID_API_KEY'])
response = sg.send(message)
print(response.status_code)from_email must be an address you verified in the console, otherwise the call returns 403 with a 'from address does not match a verified Sender Identity' body.
Send both a text and an HTML partsend-html-and-text
message = Mail(
from_email=('alerts@yourdomain.com', 'Status Bot'),
to_emails='user@example.com',
subject='Weekly summary',
plain_text_content='Plain text fallback.',
html_content='<p>Rendered <strong>summary</strong>.</p>',
)Order matters in the generated body: pass plain_text_content as well as html_content so the text/plain part is listed first, which is what the API expects.
Send one message to many people privatelysend-to-many-recipients
from sendgrid.helpers.mail import Mail, To
recipients = [To('a@example.com'), To('b@example.com'), To('c@example.com')]
message = Mail(
from_email='alerts@yourdomain.com',
to_emails=recipients,
subject='Maintenance window',
plain_text_content='Saturday 02:00 UTC.',
is_multiple=True,
)is_multiple=True builds one personalization per recipient. Leave it False and everyone sees the whole To list, which is the usual way addresses leak.
Render a dynamic templateuse-dynamic-template
message = Mail(from_email='alerts@yourdomain.com', to_emails='user@example.com')
message.template_id = 'd-1234567890abcdef1234567890abcdef'
message.dynamic_template_data = {
'first_name': 'Asha',
'invoice_total': '1,240.00',
}
response = sg.send(message)Once template_id is set the API ignores subject and any Content you attached. If the template renders empty, the usual cause is a key name that does not match the handlebars variable.
Attach a base64 encoded fileattach-a-file
import base64
from sendgrid.helpers.mail import Attachment, FileContent, FileName, FileType, Disposition
with open('invoice.pdf', 'rb') as fh:
encoded = base64.b64encode(fh.read()).decode()
message.attachment = Attachment(
FileContent(encoded),
FileName('invoice.pdf'),
FileType('application/pdf'),
Disposition('attachment'),
)The content must be base64 as a str, not bytes. Total message size including attachments is capped by the API at 30 MB, and the base64 expansion counts against it.
Dry run with sandbox modevalidate-without-sending
from sendgrid.helpers.mail import MailSettings, SandBoxMode
message.mail_settings = MailSettings()
message.mail_settings.sandbox_mode = SandBoxMode(True)
response = sg.send(message) # 200, nothing deliveredSandbox mode validates the payload and returns 200 instead of the usual 202. Use it in tests so CI never sends real mail, and assert on 200 rather than 202 there.
Read the actual error messagehandle-api-errors
import json
from python_http_client.exceptions import HTTPError
try:
response = sg.send(message)
except HTTPError as exc:
detail = json.loads(exc.body.decode())
print(exc.status_code, detail['errors'])exc.body is bytes and the useful part is the errors list. Catching a bare Exception here hides the field-level reason, which is often the only clue about a rejected payload.
Schedule delivery for laterschedule-a-send
import datetime
send_at = int((datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(hours=6)).timestamp())
message.send_at = send_atsend_at is a Unix timestamp and must be no more than 72 hours in the future. Cancelling a scheduled batch later needs a batch_id set at send time.
Add categories and custom argumentstag-for-analytics
from sendgrid.helpers.mail import Category, CustomArg
message.category = [Category('billing'), Category('invoice-reminder')]
message.custom_arg = CustomArg('tenant_id', 'acct_9182')Categories are capped at 10 per message and show up in stats. Custom args come back on Event Webhook payloads, which is how you join events to your own records.
Verify a signed Event Webhook requestverify-event-webhook
from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader
verifier = EventWebhook(os.environ['SENDGRID_WEBHOOK_PUBLIC_KEY'])
ok = verifier.verify_signature(
raw_body,
request.headers[EventWebhookHeader.SIGNATURE],
request.headers[EventWebhookHeader.TIMESTAMP],
)
if not ok:
return '', 403Pass the exact raw request body as a string. Any framework that re-serialises JSON before you get to it breaks the signature, so read the body before parsing.
Use the fluent client for non-mail endpointscall-other-endpoints
bounces = sg.client.suppression.bounces.get()
print(bounces.status_code, bounces.body)
# path segments that are not valid Python identifiers
stats = sg.client._('stats').get(query_params={'start_date': '2026-08-01'})Attribute access builds the URL path, so a typo becomes a 404 rather than an AttributeError. Use the _() escape hatch for segments with dashes or path parameters.
Route sends through the EU regionuse-eu-data-residency
sg = SendGridAPIClient(os.environ['SENDGRID_API_KEY'])
sg.set_sendgrid_data_residency('eu')
response = sg.send(message)Only 'eu' and 'global' are accepted, anything else raises ValueError. Call it before the first request because it rebuilds the underlying HTTP client, and the API key must belong to an EU-provisioned account.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| django-anymail | PyPI | You want one send API across SendGrid, SES, Mailgun, Postmark and others so the provider stays a config value |
| resend | PyPI | You are picking a transactional provider fresh and want a much smaller client surface with a maintained SDK |
| boto3 | PyPI | You are already on AWS and Amazon SES pricing and IAM integration matter more than SendGrid's template and analytics tooling |