Cookbook
URLs, S3 and AWS Lambda
pdfplumber reads any seekable file, so a PDF from the web or a bucket is one download away. Deploying it to AWS Lambda is mostly a packaging question. We built both a zip package and a container image, ran them on the official Lambda base image, and measured what you will hit.
Short answer
Download into memory and open the bytes. pdfplumber needs a file it can seek in, which a network stream is not:
import io
import pdfplumber
import requests
url = "http://127.0.0.1:8765/report.pdf" # a local test server here; any http(s) URL works the same
response = requests.get(url, timeout=30)
response.raise_for_status()
with pdfplumber.open(io.BytesIO(response.content)) as pdf: # bytes in memory, seekable
print(len(pdf.pages), "pages:", pdf.pages[0].extract_text().splitlines()[2])
# A raw network stream cannot seek backwards, which PDF parsing needs
with requests.get(url, stream=True, timeout=30) as r:
try:
pdfplumber.open(r.raw).pages
except Exception as e:
inner = e.args[0]
print("streaming directly:", type(e).__name__, "wrapping", type(inner).__name__, repr(str(inner)))2 pages: 1. Summary
streaming directly: PdfminerException wrapping UnsupportedOperation 'seek'From a URL
requests.get(url).content holds the whole file in memory, which is fine for typical documents. For very large files, stream the download into a temporary file and open that instead:
tempfile.SpooledTemporaryFile(max_size=50_000_000)keeps small files in memory and moves large ones to disk automatically; write the chunks fromresponse.iter_content(), thenseek(0)and pass it topdfplumber.open().- Check
response.headers.get("Content-Type")and the first bytes (%PDF-) before parsing: many “PDF links” return an HTML login or error page (what that looks like).
As the example shows, handing pdfplumber the raw stream fails with UnsupportedOperation: seek, wrapped in PdfminerException. The same applies to any non-seekable source, such as a pipe or sys.stdin.buffer: read it into BytesIO first.
From S3 (and other object stores)
Read the object’s body into bytes and wrap it in BytesIO. The Lambda handler below does exactly that, and accepts either an S3 location or a base64-encoded body from API Gateway:
import base64
import io
import boto3
import pdfplumber
s3 = boto3.client("s3")
def handler(event, context):
"""Extract text and tables from a PDF in S3, or from a base64 body (API Gateway)."""
if "bucket" in event:
obj = s3.get_object(Bucket=event["bucket"], Key=event["key"])
data = obj["Body"].read()
else:
data = base64.b64decode(event["body"])
pages = []
with pdfplumber.open(io.BytesIO(data)) as pdf:
for page in pdf.pages:
pages.append({"page": page.page_number,
"chars": len(page.chars),
"tables": page.extract_tables()})
page.close()
return {"pages": len(pages), "tables": sum(len(p["tables"]) for p in pages), "detail": pages}Tested locally against moto, which imitates S3 in memory, so the same handler runs in a unit test without an AWS account:
import os
import boto3
from moto import mock_aws # fake AWS in memory, for tests
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
with mock_aws():
s3 = boto3.client("s3")
s3.create_bucket(Bucket="incoming-pdfs")
s3.upload_file("samples/report.pdf", "incoming-pdfs", "2026/report.pdf")
from lambda_function import handler # import inside the mock: it creates its client
result = handler({"bucket": "incoming-pdfs", "key": "2026/report.pdf"}, None)
print(result["pages"], "pages,", result["tables"], "tables; first rows:", result["detail"][0]["tables"][0][:2])2 pages, 2 tables; first rows: [['Site', 'Result', 'Reading (ppb)'], ['Site 01', 'Pass', '3.9']]Google Cloud Storage and Azure Blob Storage work the same way: blob.download_as_bytes() (GCS) or download_blob().readall() (Azure) gives you the bytes.
On AWS Lambda
We ran the handler on the official public.ecr.aws/lambda/python:3.13 image, using the Runtime Interface Emulator that the image includes, in both deployment styles. Both extracted the same results from three sample PDFs:
| Zip package | Container image | |
|---|---|---|
| Size | 24.7 MB zipped, 58.7 MB unzipped | 928 MB image (132 MB on top of the 796 MB base) |
| Lambda limit | 50 MB zipped for direct upload, 250 MB unzipped | 10 GB image |
| Initialisation (imports) | 485 ms | 378 ms |
| First invocation (invoice.pdf) | 533 ms | 422 ms |
| Later invocations | 105 and 64 ms | 111 and 78 ms |
| Results | invoice.pdf: 1 table, bank-statement.pdf: 0 tables, report.pdf: 2 tables | Same |
Timings come from the emulator’s own reports on our 4-core test machine, on 2026-11-04; on real Lambda they depend on the memory size you configure, which also sets the CPU share. The emulator does not measure memory use, so we do not quote it. The bank statement’s zero tables is the expected result for default settings (why).
Zip package
Lambda runs on Linux, so the package needs Linux wheels even when you build it on macOS or Windows. Let pip download them for the Lambda platform, install them into a folder, add your handler and zip it:
pip install --target package --only-binary=:all: \
--platform manylinux_2_28_x86_64 --platform manylinux2014_x86_64 \
--python-version 3.13 --implementation cp pdfplumber==0.11.10
cp lambda_function.py package/ && (cd package && zip -qr9 ../function.zip .)
That gave us 8 packages: cffi 2.1.1, charset_normalizer 3.5.2, cryptography 50.0.2, pdfminer_six 20260107, pdfplumber 0.11.10, pillow 13.0.0, pycparser 3.12, pypdfium2 5.14.0. boto3 is not included because the Lambda Python runtime provides it. For arm64 (Graviton) functions, use manylinux_2_28_aarch64 and manylinux2014_aarch64; pdfplumber’s compiled dependencies publish wheels for both.
Container image
# Lambda container image with pdfplumber, built from wheels downloaded for the Lambda platform:
# pip download --dest wheels --only-binary=:all: --platform manylinux_2_28_x86_64 \
# --platform manylinux2014_x86_64 --python-version 3.13 --implementation cp pdfplumber==0.11.10
FROM public.ecr.aws/lambda/python:3.13
COPY wheels /tmp/wheels
RUN pip install --no-cache-dir --no-index --find-links /tmp/wheels pdfplumber==0.11.10 && rm -rf /tmp/wheels
COPY lambda_function.py ${LAMBDA_TASK_ROOT}/
CMD ["lambda_function.handler"]
This is the exact Dockerfile we built, with the wheels downloaded first as described in its comments; a normal pip install pdfplumber==0.11.10 in the RUN line works the same where the build has direct internet access. Choose an image over a zip when you also need system tools, such as Ghostscript for repair=True or Tesseract for OCR, which a zip package cannot easily include.
The script that built and invoked everything on this page is lab/cloud/lambda_test.py; it writes the numbers above.