How-to
Automating Reporting WorkflowsDeep dive

Run a Python Excel Report in AWS Lambda

Build an Excel report in an AWS Lambda function and save it to S3 on a schedule — a handler that works in memory, a package that fits Lambda's size limits, memory and timeout settings that suit pandas, an EventBridge Scheduler rule in local time, retries that do not produce duplicate reports, and tests that run without an AWS account.

A report that runs for thirty seconds once a day does not need a server. AWS Lambda runs the Python function on a schedule, bills for the seconds it uses, and writes the workbook to S3 where people or other systems pick it up. The friction is in the details: Lambda's defaults — 128 MB of memory, a 3-second timeout, automatic retries — suit small event handlers, not a pandas report, and the deployment package has size limits that pandas comes close to. This guide, part of Scheduling Python Excel Scripts with cron, builds the function, sizes it with measured numbers and tests it locally without an AWS account.

A scheduled report in Lambda EventBridge Scheduler invokes the function at 07:30 London time on weekdays, passing an empty event. The function builds the workbook in memory and writes it to S3 with put_object. S3 confirms, and the function returns the bucket, key and size, which appear in the logs. EventBridge Scheduler Lambda function S3 bucket 1. 07:30 Europe/London, event {} 2. workbook built in memory → put_object 3. 200 OK

Prerequisites

Bash
pip install boto3 pandas openpyxl "moto[s3]"

An S3 bucket for the reports and an execution role for the function that allows s3:PutObject on it. The Lambda Python runtime already includes boto3; moto is only for local tests.

Step 1 — Write a handler that works in memory

Lambda's file system is read-only except for /tmp. Rather than writing a file and uploading it, build the workbook in a BytesIO buffer and send the bytes straight to S3. Create the S3 client at module level, outside the handler, so warm invocations reuse it:

Python
# report_lambda.py
import datetime as dt
import io
import os

import boto3
import pandas as pd

s3 = boto3.client("s3")                      # created once per container, reused

def build_report(day):
    frame = pd.DataFrame({"region": ["North", "South", "East", "West"],
                          "sales": [182_400, 141_950, 97_300, 120_880]})
    buffer = io.BytesIO()
    with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
        frame.to_excel(writer, sheet_name=f"Sales {day:%Y-%m-%d}", index=False)
    return buffer.getvalue()

def handler(event, context):
    day = dt.date.fromisoformat(event.get("date") or dt.date.today().isoformat())
    data = build_report(day)
    key = f"reports/daily/{day:%Y/%m}/sales_{day:%Y-%m-%d}.xlsx"
    s3.put_object(Bucket=os.environ["REPORT_BUCKET"], Key=key, Body=data,
                  ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    return {"bucket": os.environ["REPORT_BUCKET"], "key": key, "bytes": len(data)}

The optional date in the event lets you rerun a specific day by invoking the function by hand with {"date": "2026-09-22"}. Lambda runs in UTC, so date.today() near midnight may not be the business day you expect; pass the date explicitly or convert with zoneinfo when it matters. Settings such as the bucket name come from environment variables on the function, not from the code. More on S3 uploads is in Upload an Excel Report to Amazon S3 with boto3.

Step 2 — Test it locally with moto

moto intercepts boto3's calls and fakes S3 in memory, so the handler can be tested on any machine and in CI:

Python
import os
os.environ.update(REPORT_BUCKET="acme-reports", AWS_DEFAULT_REGION="eu-west-2",
                  AWS_ACCESS_KEY_ID="testing", AWS_SECRET_ACCESS_KEY="testing")

import boto3
from moto import mock_aws

with mock_aws():
    boto3.client("s3").create_bucket(Bucket="acme-reports",
                                     CreateBucketConfiguration={"LocationConstraint": "eu-west-2"})
    import report_lambda
    print(report_lambda.handler({"date": "2026-09-23"}, None))
    head = boto3.client("s3").head_object(Bucket="acme-reports",
                                          Key="reports/daily/2026/09/sales_2026-09-23.xlsx")
    print(head["ContentType"])
# {'bucket': 'acme-reports', 'key': 'reports/daily/2026/09/sales_2026-09-23.xlsx', 'bytes': 4935}
# application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

Import the handler module inside mock_aws(): its module-level client must be created while the mock is active, or it would try to reach real AWS. Fake credentials guarantee a mistake cannot touch a real account.

Step 3 — Package it within Lambda's limits

A zip deployment can be at most 50 MB when uploaded directly and 250 MB unzipped, including layers. Install the dependencies for Lambda's platform, not your laptop's, and measure:

Bash
pip install --platform manylinux2014_x86_64 --implementation cp --python-version 3.12 \
            --only-binary=:all: --target package pandas openpyxl
du -sh package          # 154 MB with numpy

pandas, numpy and openpyxl came to 154 MB unzipped and 44 MB zipped — within both limits, but with less room than it seems once other libraries join. Three ways to keep it comfortable: put the dependencies in a layer shared by several report functions; use a container image, which allows up to 10 GB; or drop pandas when the report is simple, since openpyxl alone was 3 MB and XlsxWriter 2 MB. Building workbooks without pandas is covered in Write a Formatted Excel Report with XlsxWriter.

Unzipped package size against the 250 MB limit Dependencies installed for Lambda's Linux x86_64 platform and Python 3.12. pandas with numpy and openpyxl takes 154 MB unzipped, leaving under 100 MB of the 250 MB limit for everything else. openpyxl alone takes 3 MB and XlsxWriter 2 MB. Lambda unzipped limit 250 MB pandas + numpy + openpyxl 154 MB openpyxl only 3 MB XlsxWriter only 2 MB measured with pip install --platform manylinux2014_x86_64 --target

Step 4 — Replace the defaults that do not fit

Four settings decide whether the function works at all:

  • Memory. The default is 128 MB. Importing pandas, openpyxl and boto3 alone peaked at 129 MB in a local test, before reading any data. Start at 512 MB; Lambda also allocates CPU in proportion to memory, so the report runs faster. After a few runs, the REPORT line in the logs shows Max Memory Used — set the memory comfortably above it.
  • Timeout. The default is 3 seconds, and a cold start that imports pandas can use a good part of that. Set it to a few minutes, well above the slowest real run; the maximum is 15 minutes.
  • Ephemeral storage. /tmp holds 512 MB by default, configurable up to 10 GB. Building in memory avoids it, but large input files downloaded for processing need space there.
  • Architecture. arm64 functions cost less per second. Build the package for manylinux2014_aarch64 if you switch — x86_64 wheels do not run on arm64.
Bash
aws lambda update-function-configuration --function-name sales-report \
    --memory-size 512 --timeout 300 --environment "Variables={REPORT_BUCKET=acme-reports}"

Step 5 — Schedule it in local time, without duplicate runs

EventBridge Scheduler invokes the function on a cron expression and, unlike older EventBridge rules, takes a time zone, so the report stays at 07:30 local time across daylight saving changes. Its cron format has six fields, and one of day-of-month and day-of-week must be ?:

Bash
aws scheduler create-schedule --name sales-report-weekdays \
    --schedule-expression "cron(30 7 ? * MON-FRI *)" \
    --schedule-expression-timezone "Europe/London" \
    --flexible-time-window Mode=OFF \
    --target '{"Arn": "arn:aws:lambda:eu-west-2:123456789012:function:sales-report",
               "RoleArn": "arn:aws:iam::123456789012:role/scheduler-invoke-sales-report",
               "Input": "{}"}'

The scheduler invokes the function asynchronously, and Lambda retries a failed asynchronous invocation twice by default. For this handler a retry simply writes the same key again, which is harmless. For a report that emails people, three runs mean three emails; set --maximum-retry-attempts 0 with aws lambda put-function-event-invoke-config and rely on an alarm instead, or make the send step check whether it already ran, as in Skip a Report Run When the Input File Has Not Changed.

Point a CloudWatch alarm at the function's Errors metric to hear about failures; the traceback is in the function's log group.

Lambda defaults against what a pandas report needs Memory defaults to 128 MB, which pandas imports alone exceed; start at 512 MB. The timeout defaults to 3 seconds; set a few minutes. Ephemeral storage defaults to 512 MB, enough when the workbook is built in memory. Asynchronous invocations are retried twice; keep that only when the report is idempotent. Default For an Excel report memory 128 MB 512 MB, then tune timeout 3 seconds a few minutes /tmp storage 512 MB enough when building in memory async retries 2 0, or an idempotent handler

Common pitfalls

  • Writing next to the code. to_excel("report.xlsx") fails with a read-only file system error. Use /tmp or, better, memory.
  • Wheels built on macOS or Windows. A package zipped from a laptop's site-packages fails on Lambda with import errors from numpy. Always install with --platform or build in a container.
  • Secrets in environment variables. They are visible to anyone who can read the function's configuration. Keep database passwords in Secrets Manager or Parameter Store and fetch them at start-up.
  • Measuring only warm runs. The first invocation in a new container imports pandas before doing any work — 0.33 seconds on a fast laptop, and noticeably longer on a function with little memory, and therefore little CPU. A warm run of the handler above took 4 milliseconds. Time the cold start when choosing the timeout; for a once-a-day report, nearly every run is a cold start.
  • Reports that need Excel itself. Lambda cannot run desktop Excel; recalculation or PDF export needs a different approach, such as LibreOffice in a container image.

Conclusion

Build the workbook in a BytesIO buffer and put_object it to S3 with the spreadsheet content type, test the handler with moto, package pandas for Lambda's platform (154 MB unzipped, inside the 250 MB limit) or drop it for lighter reports, raise memory and timeout well above the defaults, schedule with EventBridge Scheduler in local time, and decide deliberately whether a failed run may be retried.

Frequently asked questions

Can I use pandas in AWS Lambda? Yes. pandas, numpy and openpyxl together measured 154 MB unzipped and 44 MB zipped for Python 3.12 on x86_64, inside Lambda's 250 MB unzipped limit. Package them in a layer, use a container image, or use openpyxl or XlsxWriter alone when the report does not need pandas.

How much memory does a Lambda function need for a pandas Excel report? More than the 128 MB default. Importing pandas, openpyxl and boto3 alone peaked at 129 MB in a local test. Start at 512 MB, which also gives the function more CPU, and adjust from the Max Memory Used figure in the logs.

Where can a Lambda function write an Excel file? Only under /tmp, which holds 512 MB by default. It is simpler to build the workbook in a BytesIO buffer and upload the bytes to S3 directly, so nothing is written to disk.

How do I schedule a Lambda function at a local time? Use EventBridge Scheduler with a cron expression such as cron(30 7 ? * MON-FRI *) and a schedule time zone such as Europe/London. The run then stays at 07:30 local time across daylight saving changes.

More on scheduling & orchestration

More on scheduling & orchestration →