Independent guide. Not affiliated with the pdfplumber project or its maintainers.Official repository: jsvine/pdfplumber ↗

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.

Tested with pdfplumber 0.11.10 · Python 3.13Updated 2026-11-04

Short answer

Download into memory and open the bytes. pdfplumber needs a file it can seek in, which a network stream is not:

pdf_from_url.py
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)))
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
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 from response.iter_content(), then seek(0) and pass it to pdfplumber.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:

lambda_function.py
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:

test_lambda_s3.py
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])
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
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.