Return an Excel File from AWS Lambda Through API Gateway
A serverless "download the report" endpoint is attractive: no server to patch, and it costs nothing when nobody is downloading. But Lambda functions return JSON-shaped responses, and an Excel workbook is binary. Return the bytes the obvious way and the browser receives either an error or a file full of base64 text that Excel refuses to open. The fix is small — base64-encode the body and tell API Gateway it is encoded — plus one extra setting for REST APIs, sensible headers, and a plan for files larger than Lambda's response limit. This page builds the handler, explains the two API Gateway flavours, adds input validation and the S3 fallback for big workbooks, and tests it all locally without deploying. It belongs to Serving Excel Files from Python Web Apps.
Prerequisites
pip install openpyxl boto3 moto
The handler uses openpyxl to build the workbook; openpyxl is pure Python, so it can be packaged into a Lambda deployment or layer without compiled dependencies. boto3 is already available in the Lambda Python runtime; moto is only for the local tests. The examples were written for the Python 3.13 runtime.
Step 1 — Build the workbook in memory
Lambda's file system is read-only apart from /tmp, and there is no reason to touch disk anyway. Build the workbook into a BytesIO buffer:
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font
def build_report(month, rows):
wb = Workbook()
ws = wb.active
ws.title = f"Sales {month}"
ws.append(["Region", "Orders", "Revenue"])
for cell in ws[1]:
cell.font = Font(bold=True)
for row in rows:
ws.append(row)
ws.append(["Total", f"=SUM(B2:B{len(rows) + 1})", f"=SUM(C2:C{len(rows) + 1})"])
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
data = build_report("2026-09", [("North", 41, 18400.0), ("South", 32, 12100.0), ("East", 25, 9800.0)])
print(len(data), "bytes, starts with", data[:2])
An .xlsx file is a zip archive, which is why it starts with the bytes PK. In a real function the rows would come from a database or an S3 object; the pattern for that is in Build an Excel Workbook in Memory with BytesIO.
Step 2 — Return it base64-encoded
The Lambda response is a dictionary. For binary content the body must be a base64 string and isBase64Encoded must be True, so API Gateway decodes it back to bytes before sending it to the client:
import base64
import json
import re
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
MONTH = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def fetch_rows(month):
return [("North", 41, 18400.0), ("South", 32, 12100.0), ("East", 25, 9800.0)]
def error(status, message):
return {"statusCode": status, "headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": message})}
def handler(event, context):
params = event.get("queryStringParameters") or {}
month = params.get("month", "")
if not MONTH.match(month):
return error(400, "month must look like 2026-09")
body = build_report(month, fetch_rows(month))
return {
"statusCode": 200,
"headers": {
"Content-Type": XLSX,
"Content-Disposition": f'attachment; filename="sales-report_{month}.xlsx"',
"Cache-Control": "no-store",
},
"body": base64.b64encode(body).decode("ascii"),
"isBase64Encoded": True,
}
resp = handler({"queryStringParameters": {"month": "2026-09"}}, None)
print(resp["statusCode"], resp["headers"]["Content-Disposition"], resp["isBase64Encoded"])
print(handler({"queryStringParameters": {"month": "September"}}, None))
Content-Type tells the browser what it is receiving, and Content-Disposition: attachment with a filename makes it save the file as sales-report_2026-09.xlsx instead of trying to display it. Cache-Control: no-store stops browsers and proxies keeping a copy of a report that may contain sensitive figures. The query parameter is validated with a strict pattern before it is used anywhere — it ends up in a file name and probably a database query, so anything unexpected is rejected with a 400 and a JSON message. Filling in fetch_rows and checking who is asking (an authoriser on the API, or a token checked in the handler) completes the endpoint.
Step 3 — HTTP API or REST API
API Gateway has two flavours, and they treat binary responses differently:
- HTTP APIs (and Lambda function URLs) honour
isBase64Encodedwith no further configuration. This is the simpler choice for a download endpoint. - REST APIs only decode the body if the response's content type is listed in the API's binary media types and the request's
Acceptheader matches one of them. Addapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet(or*/*) under Settings → Binary media types, and redeploy the stage. Without it, the client receives the base64 text itself — a "corrupt" file that is actually a few hundred kilobytes of letters.
In infrastructure code the REST setting is one line — binaryMediaTypes in CloudFormation or SAM, binary_media_types in Terraform. Both flavours limit how long a request may run (around 30 seconds by default), so a report that takes longer to build should be generated in the background, as described in Generate Large Excel Exports in a Background Job.
Step 4 — Test the handler locally
The handler is a plain function, so tests call it with an event dictionary and decode the result exactly as API Gateway would:
from openpyxl import load_workbook
def as_api_gateway_would(resp):
raw = base64.b64decode(resp["body"]) if resp.get("isBase64Encoded") else resp["body"].encode()
return resp["statusCode"], resp["headers"], raw
event = {"version": "2.0", "rawPath": "/report", "queryStringParameters": {"month": "2026-09"},
"requestContext": {"http": {"method": "GET"}}}
status, headers, raw = as_api_gateway_would(handler(event, None))
ws = load_workbook(BytesIO(raw)).active
print(status, headers["Content-Type"] == XLSX, ws.title, [c.value for c in ws[ws.max_row]])
status, _, raw = as_api_gateway_would(handler({"queryStringParameters": None}, None))
print(status, raw.decode())
The decoded bytes open as a workbook with the expected sheet and total row, and a request without parameters — queryStringParameters is None rather than an empty dict when the URL has no query string, which is why the handler uses or {} — gets a 400. For an end-to-end check after deployment, curl -o report.xlsx "https://…/report?month=2026-09" followed by file report.xlsx should report a Microsoft Excel 2007+ file, not ASCII text.
Step 5 — Bigger files: upload to S3 and redirect
A synchronous Lambda response is limited to 6 MB, and base64 encoding inflates the workbook by a third — so anything much over 4 MB cannot be returned directly. For those, write the workbook to S3 and return a short-lived pre-signed URL, either as a redirect or as JSON for a front end:
import boto3
from moto import mock_aws
LIMIT = 6 * 1024 * 1024
def deliver(month, body, bucket="acme-report-downloads", s3=None):
encoded = base64.b64encode(body).decode("ascii")
if len(encoded) < LIMIT - 64 * 1024: # leave room for headers
return {"statusCode": 200, "headers": {"Content-Type": XLSX,
"Content-Disposition": f'attachment; filename="sales-report_{month}.xlsx"'},
"body": encoded, "isBase64Encoded": True}
key = f"downloads/sales-report_{month}.xlsx"
s3.put_object(Bucket=bucket, Key=key, Body=body, ContentType=XLSX)
url = s3.generate_presigned_url("get_object", ExpiresIn=300, Params={
"Bucket": bucket, "Key": key,
"ResponseContentDisposition": f'attachment; filename="sales-report_{month}.xlsx"'})
return {"statusCode": 302, "headers": {"Location": url, "Cache-Control": "no-store"}, "body": ""}
with mock_aws():
s3 = boto3.client("s3", region_name="eu-west-1")
s3.create_bucket(Bucket="acme-report-downloads", CreateBucketConfiguration={"LocationConstraint": "eu-west-1"})
small = deliver("2026-09", data, s3=s3)
big = deliver("2026-09", data + b"\0" * (5 * 1024 * 1024), s3=s3)
print("small:", small["statusCode"], "| big:", big["statusCode"], big["headers"]["Location"].split("?")[0])
from urllib.parse import parse_qs, urlsplit
print("query keys:", sorted(parse_qs(urlsplit(big["headers"]["Location"]).query)))
Small reports still come straight back; the 5 MB-plus one is stored in S3 and the client is redirected to a URL that works for five minutes. The signed query string carries the expiry, the signature and a response-content-disposition override (the exact parameter names depend on the signature version boto3 picks for your region and configuration). ResponseContentDisposition makes S3 send the download filename, so the browser saves it just as it would the direct response. The Lambda function's role needs s3:PutObject on the bucket, and a lifecycle rule that deletes downloads/ objects after a day keeps the bucket from filling up. (The padded bytes in the example only simulate size; a real large workbook works the same way.) Uploading in general is covered in Upload an Excel Report to Amazon S3 with boto3.
Common pitfalls
- Raw bytes in
body. The response must be JSON-serialisable; base64-encode and setisBase64Encoded. - REST API without binary media types. The client receives base64 text; add the type and redeploy.
- No
Content-Disposition. Browsers may try to display the file or name itreport. - Assuming
queryStringParametersis a dict. It isNonewhen there is no query string. - Workbooks over about 4.5 MB. They exceed the 6 MB response limit once encoded; use S3.
Conclusion
To return an Excel file from Lambda through API Gateway, build the workbook in memory, base64-encode it into the response body with isBase64Encoded: True, and set Content-Type and Content-Disposition headers. HTTP APIs and function URLs need nothing more; REST APIs also need the xlsx type in their binary media types. Validate query parameters before using them, test the handler locally by decoding its response, and send workbooks too large for the 6 MB limit through S3 with a short-lived pre-signed URL.
Frequently asked questions
How do I return a binary file from AWS Lambda? Base64-encode the bytes, put the string in the response body, set isBase64Encoded to true and add the Content-Type header. API Gateway decodes the body before sending it to the client.
Why is the Excel file from my API Gateway endpoint corrupt? On a REST API the response was not decoded, so the file contains base64 text. Add the xlsx content type or / to the API's binary media types, make sure the request's Accept header matches, and redeploy.
What is the maximum file size Lambda can return? A synchronous response is limited to 6 MB, and base64 adds about a third, so a workbook of roughly 4.5 MB is the practical maximum. Larger files should go through S3 with a pre-signed URL.
How do I make the browser download the file with a name? Send a Content-Disposition header of attachment with a filename, for example attachment; filename="report.xlsx".
Related
- Up one level: Serving Excel Files from Python Web Apps — Excel downloads from web apps.
- Run an Excel Report in AWS Lambda — scheduled rather than on-demand.
- Return an Excel File from a Flask Download Endpoint — the same download from a server.
- Stream an Excel File from a FastAPI Endpoint — streaming large workbooks.
More on excel in web apps
- How-toSchedule Excel Reports with Celery Beat
- How-toTrigger an Excel Report from a Webhook
- How-toAdd an Export to Excel Action in Django Admin
- How-toBuild a Gradio App That Cleans an Uploaded Excel File
- How-toBuild a Streamlit App to Upload and Download Excel Files
- How-toBuild an Excel Workbook in Memory with BytesIO