Attach Multiple Excel Files to One Email in Python
One email, several workbooks: a report per region, a summary plus its detail, a month's worth of daily files. EmailMessage handles it with a loop — and then the practical questions arrive. What MIME type does each file need, how large can the message be before a gateway rejects it, and how do you know all of them actually made it in? This guide covers the loop and the guard rails around it. It extends Emailing Excel Reports with smtplib.
Prerequisites
pip install pandas xlsxwriter
Everything else is standard library. Some files to send:
from pathlib import Path
import pandas as pd
Path("out").mkdir(exist_ok=True)
for region in ("north", "south", "west"):
pd.DataFrame({"metric": ["revenue", "units"], "value": [5150.0, 412]}).to_excel(
f"out/{region}-2026-08.xlsx", index=False
)
Step 1 — Attach with the right MIME type
mimetypes maps an extension to a type, and a small override table covers the Office formats it does not always know:
import mimetypes
from pathlib import Path
OVERRIDES = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".xls": "application/vnd.ms-excel",
".csv": "text/csv",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
}
def mime_for(path):
"""Return (maintype, subtype) for a file, from its extension."""
suffix = Path(path).suffix.lower()
guessed = OVERRIDES.get(suffix) or mimetypes.guess_type(str(path))[0]
guessed = guessed or "application/octet-stream"
maintype, _, subtype = guessed.partition("/")
return maintype, subtype
Getting the type right matters more than it sounds. Sent as application/octet-stream, an .xlsx arrives as a generic binary that some clients refuse to open and some gateways quarantine.
The attachment loop is then short:
from email.message import EmailMessage
from email.utils import formataddr
from pathlib import Path
def attach_all(msg, paths):
"""Attach every file, each with its own MIME type. Returns what was added."""
attached = []
for path in paths:
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"cannot attach missing file: {path}")
if path.stat().st_size == 0:
raise ValueError(f"refusing to attach an empty file: {path}")
maintype, subtype = mime_for(path)
msg.add_attachment(
path.read_bytes(),
maintype=maintype, subtype=subtype, filename=path.name,
)
attached.append(path.name)
return attached
Refusing an empty file is worth the two lines. A zero-byte attachment is almost always an upstream failure, and sending it turns a job failure into a support conversation.
Step 2 — Budget the size before building
Base64 inflates every attachment by roughly a third, and the limit that matters is the encoded size — plus the receiving server's limit, which you do not control and which may be lower than your own:
from pathlib import Path
BASE64_OVERHEAD = 4 / 3
def encoded_size_mb(paths):
"""Approximate on-the-wire size of a set of attachments."""
raw = sum(Path(p).stat().st_size for p in paths)
return raw * BASE64_OVERHEAD / (1024 * 1024)
def check_budget(paths, limit_mb=20):
size = encoded_size_mb(paths)
if size > limit_mb:
biggest = max(paths, key=lambda p: Path(p).stat().st_size)
raise ValueError(
f"attachments encode to about {size:.1f} MB, over the {limit_mb} MB "
f"budget. Largest is {Path(biggest).name}. Zip them, or publish "
f"and send a link."
)
return size
Setting the budget below the provider's actual limit leaves headroom for the body, the headers and any gateway that rewrites the message on the way through.
Step 3 — Zip when there are many
Beyond a handful of files, a single archive is both smaller and easier for the recipient. Build it in memory so no temporary files are left behind:
import io
import zipfile
from pathlib import Path
def zip_bytes(paths, arcname_prefix=""):
"""Zip a set of files into memory and return (bytes, per-file sizes)."""
buffer = io.BytesIO()
sizes = {}
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED,
compresslevel=6) as archive:
for path in paths:
path = Path(path)
archive.write(path, arcname=f"{arcname_prefix}{path.name}")
sizes[path.name] = path.stat().st_size
return buffer.getvalue(), sizes
Be realistic about the gain. An .xlsx is already a zip of compressed XML, so a single workbook typically shrinks by only a few per cent. What zipping actually buys you is one attachment instead of twenty, plus real compression on any CSV or text files in the set — those often compress by eighty per cent or more.
Two cautions. Many corporate gateways block .zip attachments outright, so confirm the recipients can receive one before relying on it. And never password-protect the archive and send the password in the same message; that provides no protection and often triggers the gateway rules that block encrypted archives.
Step 4 — Decide between attaching, zipping and linking
The choice follows from the count and the size, and it is worth encoding as a rule rather than deciding case by case.
from email.message import EmailMessage
from email.utils import formataddr
from pathlib import Path
def build_multi_attachment(paths, sender, recipients, subject, body,
zip_over=5, limit_mb=20, zip_name="reports.zip"):
"""One message carrying several files, zipping when there are many."""
paths = [Path(p) for p in paths]
size_mb = encoded_size_mb(paths)
msg = EmailMessage()
msg["From"] = formataddr(("Reporting", sender))
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
if size_mb > limit_mb:
raise ValueError(
f"{size_mb:.1f} MB encoded — publish these and send a link instead"
)
if len(paths) > zip_over:
payload, sizes = zip_bytes(paths)
listing = "\n".join(f" {name} ({size:,} bytes)"
for name, size in sizes.items())
msg.set_content(f"{body}\n\nAttached as {zip_name}:\n{listing}\n")
msg.add_attachment(payload, maintype="application", subtype="zip",
filename=zip_name)
return msg, [zip_name]
listing = "\n".join(f" {p.name}" for p in paths)
msg.set_content(f"{body}\n\nAttached:\n{listing}\n")
return msg, attach_all(msg, paths)
Listing the filenames in the body is a small courtesy that pays off: a recipient whose client hides attachments, or whose gateway stripped one, can tell what was meant to arrive.
Step 5 — Verify before sending
Walk the built message and confirm every intended file is there. It is three lines and it catches the case where a path silently did not resolve:
def attached_filenames(msg):
"""Every filename actually present in the built message."""
return [
part.get_filename()
for part in msg.walk()
if part.get_content_disposition() == "attachment"
]
msg, intended = build_multi_attachment(
sorted(Path("out").glob("*.xlsx")),
sender="reporting@example.com",
recipients=["team@example.com"],
subject="Regional reports — August 2026",
body="The August regional reports are attached.",
)
present = attached_filenames(msg)
missing = set(intended) - set(present)
assert not missing, f"not attached: {sorted(missing)}"
print(f"{len(present)} attachment(s):", present)
Then send with a single connection, as covered in the parent topic:
import os
import smtplib
with smtplib.SMTP(os.environ["SMTP_HOST"],
int(os.environ.get("SMTP_PORT", 587)), timeout=30) as smtp:
smtp.starttls()
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
smtp.send_message(msg)
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Attachment will not open | Sent as octet-stream | Set the correct MIME subtype per file. |
| Message bounces as too large | Budgeted the raw size | Multiply by 4/3 for base64. |
| A file silently missing | Path did not resolve | Raise on a missing path; verify the parts. |
| Zero-byte attachment sent | Upstream produced nothing | Refuse empty files. |
| Zip stripped by the gateway | Corporate policy blocks archives | Attach individually, or link. |
| Zipping barely helped | .xlsx is already compressed | The gain is one attachment, not size. |
| Duplicate filenames in the zip | Same name from different folders | Prefix the archive names. |
| Recipient sees no attachments | Client hides them | List the filenames in the body. |
Performance and scale notes
The whole message is assembled in memory before sending, so peak usage is roughly the sum of the attachments plus their base64 expansion — call it 2.3 times the raw bytes. For a handful of report-sized files that is nothing; for a hundred it is worth thinking about.
Three habits. Read each file once. Where the same workbook goes to several recipients, read the bytes once and reuse them across messages rather than re-reading per message:
from pathlib import Path
payloads = {p.name: p.read_bytes() for p in Path("out").glob("*.xlsx")}
Compress only what compresses. Running ZIP_DEFLATED over already-compressed .xlsx files costs CPU for a few per cent. Store them and deflate only the text files:
import io, zipfile
from pathlib import Path
ALREADY_COMPRESSED = {".xlsx", ".xlsm", ".zip", ".png", ".jpg", ".pdf"}
def zip_smart(paths):
"""Deflate text-like files; store the ones already compressed."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for path in map(Path, paths):
method = (zipfile.ZIP_STORED
if path.suffix.lower() in ALREADY_COMPRESSED
else zipfile.ZIP_DEFLATED)
archive.write(path, arcname=path.name, compress_type=method)
return buffer.getvalue()
Prefer publishing above a threshold. Attachments scale multiplicatively — twenty recipients times a 10 MB message is 200 MB moved and stored — where a published file plus a link is one copy however many people receive it. The publish-and-link pattern is in publishing Excel reports to cloud storage, and pairing it with a summary body keeps the email useful on its own.
Conclusion
Attaching several workbooks is a loop, wrapped in the checks that keep it reliable: the correct MIME type per file so clients will open them, a refusal on missing or empty files, and a size budget computed against the base64-encoded total rather than what the files measure on disk. Above about five files, send one zip instead — remembering that the gain is a single attachment rather than much compression, since .xlsx is already a zip. Above about ten megabytes, publish and link. And always verify the built message's parts before sending, because a path that quietly did not exist looks exactly like a message that worked.
Frequently asked questions
Is there a limit on how many attachments one message can have? Not in the format itself, but every gateway limits the total encoded size, and many recipients find more than a handful unwieldy. Above about five files, zip them or publish and link instead.
How much does base64 encoding add? Roughly a third. Four megabytes of files become about 5.3 megabytes on the wire, so budget against the encoded size rather than what the files measure on disk.
Do all the attachments need the same MIME type? No, and they should not. Look up each file's type from its extension so a PDF is sent as a PDF and a CSV as text — a wrong type makes some clients refuse to open the file.
Should I zip the attachments?
When there are many, or the total is large. Excel files compress well because they are already zip containers of XML, though a single .xlsx gains little. Some corporate gateways block zip attachments, so check before relying on it.
How do I confirm every file was attached? Walk the built message's parts and compare the filenames against what you intended. It is three lines and it catches a path that silently did not exist.
Related
- Up to the parent: Emailing Excel Reports with smtplib — the connection and single-attachment basics.
- Email an Excel Report with an HTML Summary Body — putting the figures in the message itself.
- Send an Excel Report to Multiple Recipients with Python — addressing several people safely.
- Generate One Excel Report per Region in a Loop — producing the set of files this attaches.
- Publishing Excel Reports to Cloud Storage — the alternative when the set gets large.