Guide
Automating Reporting WorkflowsDeep dive

Attach Multiple Excel Files to One Email in Python

Send several workbooks in one message — loop the attachments with the right MIME type, guard the total size, zip when it grows, and verify every file made it in.

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.

Four files, four MIME types, one size budget Four files of different kinds are attached to a single message. Each carries the MIME type its extension implies: spreadsheetml for the two xlsx workbooks, application pdf for the summary, and text csv for the extract. Before sending, the combined size is multiplied by four thirds to account for base64 encoding and checked against the gateway limit, so an over-large message fails locally with a clear message rather than bouncing. the files north-2026-08.xlsx · spreadsheetml south-2026-08.xlsx · spreadsheetml summary.pdf · application/pdf extract.csv · text/csv size × 4/3 base64 overhead under limit send over limit zip or link

Prerequisites

Bash
pip install pandas xlsxwriter

Everything else is standard library. Some files to send:

Python
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:

Python
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:

Python
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:

Python
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:

Python
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.

Attach, zip, or publish and link Three bands by file count and total size. Up to about five files totalling under ten megabytes, attach them individually so recipients can open one without unpacking anything. More than five small files are better as a single zip archive, provided the recipients' gateway accepts zips. Anything totalling more than about ten megabytes should be published to storage and linked, because the message will otherwise be near or over the gateway limit. attach individually up to ~5 files under ~10 MB total recipients open one file without unpacking anything the friendliest option zip into one archive more than ~5 files still under the limit one attachment, not twenty big gain on CSV, small on xlsx check the gateway allows zips publish and link over ~10 MB total or many recipients one stored copy, not one per mailbox scales without limit
Python
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

Compare what you meant to attach with what is in the message The intended list of four filenames is compared against the filenames actually present in the built message, found by walking its parts and keeping those whose content disposition is attachment. A file missing from the second list means a path did not resolve or a step was skipped. Catching that before sending turns a silently incomplete email into a job failure with a clear message. intended north.xlsx · south.xlsx west.xlsx · east.xlsx 4 files vs in the message north.xlsx · south.xlsx west.xlsx 3 files east.xlsx missing fail before sending three lines of comparison, and a silently incomplete email becomes a clear job failure

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:

Python
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:

Python
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

SymptomCauseFix
Attachment will not openSent as octet-streamSet the correct MIME subtype per file.
Message bounces as too largeBudgeted the raw sizeMultiply by 4/3 for base64.
A file silently missingPath did not resolveRaise on a missing path; verify the parts.
Zero-byte attachment sentUpstream produced nothingRefuse empty files.
Zip stripped by the gatewayCorporate policy blocks archivesAttach individually, or link.
Zipping barely helped.xlsx is already compressedThe gain is one attachment, not size.
Duplicate filenames in the zipSame name from different foldersPrefix the archive names.
Recipient sees no attachmentsClient hides themList 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:

Python
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:

Python
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.