Reading Parquet in AWS Lambda without PyArrow

Correct at time of publishing. For up-to-date performance and feature information, see rugo.dev.

A Lambda deployment package can be 250 MB unzipped. PyArrow uses 69% of that on its own, Polars 79%, and neither can be uploaded directly to Lambda — both have to go via S3. rugo uses 5.4%. Those are measured numbers, and so is the more awkward one further down: on whole-file scans, DuckDB and Polars still read faster than rugo.

This is a walkthrough of putting a Parquet reader in a Lambda function — the deployment package, the handler code, and what it actually costs — comparing rugo, PyArrow, DuckDB and Polars. Everything here was built and deployed to AWS, not estimated.

The constraint

Lambda imposes three limits that matter here:

A Parquet reader is rarely the only thing in the package. If the function also needs a model, an SDK, or a few application dependencies, the difference between spending 14 MB and 173 MB of the budget decides whether the thing deploys.

What each library costs

Each package was built exactly as a Lambda package is built — pip install <lib> --target ./package with Lambda's platform tags — then zipped. boto3 is provided by the runtime and is not bundled.

Library Unzipped Of the 250 MB Zipped Direct upload?
rugo 0.5.4 13.5 MB 5.4% 5.3 MB yes
duckdb 1.5.6 61.7 MB 24.7% 21.8 MB yes
pyarrow 26.0.0 172.5 MB 69.0% 55.4 MB no
polars 2.0.0 196.3 MB 78.5% 55.6 MB no

The last column is not a rounding judgement. Attempting a direct upload of the PyArrow or Polars package returns:

An error occurred (RequestEntityTooLargeException) when calling the
CreateFunction operation: Request must be smaller than 70167211 bytes
for the CreateFunction operation

A 55 MB zip looks like it should fit under a 70 MB cap — but the API takes the payload base64-encoded, which inflates it by a third to roughly 74 MB. Both packages have to be staged through S3 and deployed with --code S3Bucket=…,S3Key=…. PyArrow used to clear the bar: version 25.0.1's 48.8 MB zip fitted with about 7% to spare. Version 26.0.0 grew past it.

The deployment package

Build it on Linux, or with Linux platform tags, or the compiled wheels will be wrong for the runtime:

pip install rugo \
  --target ./package \
  --platform manylinux_2_34_x86_64 \
  --python-version 3.12 \
  --only-binary=:all: \
  --implementation cp

cp lambda_function.py ./package/
cd package && zip -r ../function.zip . && cd ..

aws lambda create-function \
  --function-name parquet-reader \
  --runtime python3.12 \
  --role arn:aws:iam::<account>:role/<your-lambda-role> \
  --handler lambda_function.handler \
  --zip-file fileb://function.zip \
  --timeout 120 --memory-size 1024 \
  --environment "Variables={BUCKET=your-bucket,KEY=events.parquet}"

manylinux_2_34_x86_64 matters. Lambda's python3.12 runtime is Amazon Linux 2023 (glibc 2.34), which is new enough for those wheels. Pinning the older manylinux2014 tag silently resolves some libraries to much older releases than you asked for — that happened while preparing this article, and produced a PyArrow 20 package when the intent was PyArrow 25.

The handler

rugo has no S3 filesystem — it reads a path or a bytes buffer, so you fetch the object yourself with boto3, which the runtime already provides. This handler reads two of eight columns, filters, and averages:

import os
import time

import boto3
from rugo import parquet

BUCKET = os.environ["BUCKET"]
KEY = os.environ["KEY"]
LOCAL = "/tmp/events.parquet"

_s3 = boto3.client("s3")


def handler(event, context):
    # /tmp survives between invocations on a warm environment, so a
    # cold start pays for this download and later invocations do not.
    if not os.path.exists(LOCAL):
        _s3.download_file(BUCKET, KEY, LOCAL)

    total = 0.0
    rows = 0
    with parquet.read_parquet(
        LOCAL,
        columns=["status", "response_ms"],
        predicates=[("status", "==", 200)],
    ) as reader:
        for morsel in reader:
            total += morsel.column("response_ms").sum()
            rows += len(morsel)

    return {"rows": rows, "mean_ms": round(total / rows, 4)}

That is the whole integration. pip install rugo pulls nothing else in, so there is no dependency resolution to manage and no transitive wheel to pin.

The benchmark

One file in S3: 38.6 MB of Parquet, 2 million rows, 8 columns, 20 row groups, ZSTD compressed — web-traffic shaped. Four Lambda functions, one per library, all python3.12 at 1024 MB in us-east-1, all doing the same work through the same shape of code.

Three query shapes, because one would not be honest:

The middle and last shapes are both "selective" in the sense of returning few rows, and they behave completely differently. Any Parquet benchmark that does not separate them is measuring luck.

All four libraries returned identical answers on all three shapes: 1,840,303 rows averaging 149.3266 ms, 40,193 rows averaging 149.3458 ms, and 50,000 rows averaging 149.3672 ms. Nothing below is a difference in what was computed.

Cold starts

Cold starts were forced by mutating each function's environment, which retires the execution environment, then reading Init Duration from the REPORT line. Init duration is the median of 20 cold starts per library; the rest is the median of five on this query.

Library Init duration Handler (broad) First request total Peak memory
rugo 551 ms 799 ms 1,350 ms 174 MB
duckdb 845 ms 780 ms 1,625 ms 205 MB
pyarrow 1,011 ms 1,065 ms 2,076 ms 319 MB
polars 1,201 ms 1,285 ms 2,486 ms 280 MB

rugo initialises fastest by a wide margin — 1.5× faster than DuckDB, 1.8× faster than PyArrow, 2.2× faster than Polars. Package size and import cost are the same story told twice.

The handler times include downloading the file from S3, about 600 ms for every library, so the read itself is the smaller part. DuckDB's handler finishes 19 ms sooner than rugo's, nowhere near enough to repay its 294 ms slower start. On first-request latency for this workload, rugo wins: 1,350 ms against DuckDB's 1,625 ms.

Read throughput, warm

With the file already on /tmp, the download drops out and what remains is the read:

Query shape rugo pyarrow duckdb polars
broad (92% pass, 2 cols) 123 ms 145 ms 100 ms 80 ms
selective (2% pass, unclustered) 101 ms 102 ms 82 ms 82 ms
pruned (2% pass, clustered) 6.4 ms 5.8 ms 6.7 ms 5.8 ms

rugo reads faster than PyArrow on the broad scan and level with it on the selective one. Row-group pruning does its job — 123 ms down to 6.4 ms, a 19× improvement once the predicate lines up with the data layout — and once the reader skips most of the file, all four finish within a millisecond of each other.

This is worth stating plainly, on rugo's own site: if your Lambda's job is to scan a lot of Parquet, rugo is not the fastest way to do it. DuckDB and Polars read the broad scan 20–35% faster. They have poured years into vectorised decode and it shows.

So when does rugo win?

The arithmetic is simple, and it is about the ratio of startup to work.

rugo starts roughly 300–650 ms sooner than the others. It then reads a full scan of this file 20–45 ms slower than DuckDB and Polars, and faster than PyArrow. If your function does one modest read per invocation, or starts cold often, rugo is ahead. If it stays warm and scans hundreds of megabytes per invocation, the deficit compounds and something else is the right answer. The follow-up works out where the break-even sits on your bill.

Concretely, rugo is the better choice when:

And it is the wrong choice when:

What rugo cannot do

Beyond the capability gaps above, these are the concrete limits found while building this, all verified on rugo 0.5.4 against files written by PyArrow 26.0.0:

Reproducing this

Every number here came from a deployed function, not a local approximation. Sizes are from pip install --target trees built with Lambda's platform tags; cold starts are Init Duration from Lambda's own REPORT lines, median of 20 forced cold starts per library; read times are the median of ten warm invocations with the object already on /tmp. Python 3.12, x86_64, 1024 MB, us-east-1, measured 17 October 2026 against rugo 0.5.4, pyarrow 26.0.0, duckdb 1.5.6 and polars 2.0.0. The harness and raw results are in benchmarks/lambda-cost.

If your workload looks different from this one — and it probably does — the shape of the answer matters more than the numbers. Measure your own file, at your own memory setting, with your own query.