@shinyaz

Lambda Rust × DynamoDB — How Much Does the 90x Speed Gap Shrink in Real Workloads?

Table of Contents

Introduction

Lambda Rust is 90x faster than Python — but only in a Hello World benchmark without any SDK.

In the previous post, a synthetic benchmark with CPU and memory workloads showed Rust achieving 90x the execution speed of Python. But real applications talk to DynamoDB, S3, and other services. What happens to that 90x gap when network I/O enters the picture?

This article uses AWS SDK for Rust (aws-sdk-dynamodb) to execute various DynamoDB operations and compares latency against an equivalent Python (boto3) function. Operations are classified into "DynamoDB latency-dominant" and "client processing-dominant" patterns to quantify where Rust's advantage persists.

Test Environment

ItemValue
Regionap-northeast-1 (Tokyo)
Architecturearm64 (Graviton)
Memory128 MB
Rust version1.94.1
cargo-lambda1.9.1
aws-sdk-dynamodb1.110.0
Python runtimepython3.13
DynamoDBOn-demand capacity

The DynamoDB table uses a composite key with partition key pk (String) and sort key sk (String). Each item contains a data field (1KB string) and timestamp.

The Rust deployment package is 11MB — roughly 9x larger than the Part 1 SDK-free build (1.2MB). The AWS SDK's HTTP client (hyper + rustls) and DynamoDB client account for most of the increase.

Prerequisites:

  • Rust toolchain + cargo-lambda
  • AWS CLI configured (lambda:*, iam:*, dynamodb:* permissions)

Implementation

Both Rust and Python functions switch operations via a query parameter op. The SDK client is initialized in the Init phase (outside the handler), following Lambda best practices. This allows HTTP connection pool reuse from the second invocation onward.

Each operation's timing is measured within the application as sdk_call_ms (total SDK call time), recorded alongside Lambda's Duration (which includes runtime overhead).

Rust function code (Cargo.toml + src/main.rs)
Cargo.toml
[package]
name = "rust-dynamodb-bench"
version = "0.1.0"
edition = "2021"
 
[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-dynamodb = "1"
lambda_http = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros"] }
src/main.rs
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::Client;
use lambda_http::{Body, Error, Request, RequestExt, Response, run, service_fn};
use serde::Serialize;
use std::time::Instant;
 
#[derive(Serialize)]
struct BenchResult {
    operation: String,
    runtime: String,
    duration_ms: f64,
    sdk_call_ms: f64,
    items_count: usize,
}
 
async fn function_handler(
    client: &Client,
    event: Request,
) -> Result<Response<Body>, Error> {
    let params = event.query_string_parameters();
    let op = params.first("op").unwrap_or("get");
 
    let total_start = Instant::now();
    let mut items_count: usize = 0;
 
    let sdk_start = Instant::now();
    match op {
        "put" => {
            client.put_item()
                .table_name("lambda-rust-bench")
                .item("pk", AttributeValue::S("bench".into()))
                .item("sk", AttributeValue::S("item-0".into()))
                .item("data", AttributeValue::S("x".repeat(1000)))
                .item("timestamp", AttributeValue::N(chrono_now().to_string()))
                .send().await?;
            items_count = 1;
        }
        "get" => {
            let resp = client.get_item()
                .table_name("lambda-rust-bench")
                .key("pk", AttributeValue::S("bench".into()))
                .key("sk", AttributeValue::S("item-0".into()))
                .send().await?;
            items_count = if resp.item().is_some() { 1 } else { 0 };
        }
        "query" => {
            let resp = client.query()
                .table_name("lambda-rust-bench")
                .key_condition_expression("pk = :pk")
                .expression_attribute_values(":pk", AttributeValue::S("bench".into()))
                .send().await?;
            items_count = resp.count() as usize;
        }
        "batch_write" => {
            use aws_sdk_dynamodb::types::{WriteRequest, PutRequest};
            let requests: Vec<WriteRequest> = (0..25).map(|i| {
                let mut item = std::collections::HashMap::new();
                item.insert("pk".into(), AttributeValue::S("bench".into()));
                item.insert("sk".into(), AttributeValue::S(format!("item-{}", i)));
                item.insert("data".into(), AttributeValue::S("x".repeat(1000)));
                item.insert("timestamp".into(), AttributeValue::N(chrono_now().to_string()));
                WriteRequest::builder()
                    .put_request(PutRequest::builder().set_item(Some(item)).build().unwrap())
                    .build()
            }).collect();
            client.batch_write_item()
                .request_items("lambda-rust-bench", requests)
                .send().await?;
            items_count = 25;
        }
        _ => {}
    }
    let sdk_call_ms = sdk_start.elapsed().as_secs_f64() * 1000.0;
    let duration_ms = total_start.elapsed().as_secs_f64() * 1000.0;
 
    let result = BenchResult {
        operation: op.to_string(),
        runtime: "rust".to_string(),
        duration_ms, sdk_call_ms, items_count,
    };
    let body = serde_json::to_string(&result)?;
    let resp = Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(body.into())
        .map_err(Box::new)?;
    Ok(resp)
}
 
fn chrono_now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
}
 
#[tokio::main]
async fn main() -> Result<(), Error> {
    let config = aws_config::load_defaults(
        aws_config::BehaviorVersion::latest(),
    ).await;
    let client = Client::new(&config);
 
    run(service_fn(|event: Request| {
        let client = client.clone();
        async move { function_handler(&client, event).await }
    })).await
}
Python function code (lambda_function.py)
lambda_function.py
import json
import time
import boto3
import os
 
client = boto3.client("dynamodb", region_name="ap-northeast-1")
TABLE_NAME = os.environ.get("TABLE_NAME", "lambda-rust-bench")
 
 
def lambda_handler(event, context):
    params = event.get("queryStringParameters") or {}
    op = params.get("op", "get")
 
    total_start = time.perf_counter()
    items_count = 0
 
    sdk_start = time.perf_counter()
    if op == "put":
        client.put_item(
            TableName=TABLE_NAME,
            Item={
                "pk": {"S": "bench"},
                "sk": {"S": "item-0"},
                "data": {"S": "x" * 1000},
                "timestamp": {"N": str(int(time.time()))},
            },
        )
        items_count = 1
    elif op == "get":
        resp = client.get_item(
            TableName=TABLE_NAME,
            Key={"pk": {"S": "bench"}, "sk": {"S": "item-0"}},
        )
        items_count = 1 if "Item" in resp else 0
    elif op == "query":
        resp = client.query(
            TableName=TABLE_NAME,
            KeyConditionExpression="pk = :pk",
            ExpressionAttributeValues={":pk": {"S": "bench"}},
        )
        items_count = resp.get("Count", 0)
    elif op == "batch_write":
        requests = []
        for i in range(25):
            requests.append({
                "PutRequest": {
                    "Item": {
                        "pk": {"S": "bench"},
                        "sk": {"S": f"item-{i}"},
                        "data": {"S": "x" * 1000},
                        "timestamp": {"N": str(int(time.time()))},
                    }
                }
            })
        client.batch_write_item(RequestItems={TABLE_NAME: requests})
        items_count = 25
 
    sdk_call_ms = (time.perf_counter() - sdk_start) * 1000
    duration_ms = (time.perf_counter() - total_start) * 1000
 
    return {
        "statusCode": 200,
        "headers": {"content-type": "application/json"},
        "body": json.dumps({
            "operation": op,
            "runtime": "python",
            "duration_ms": round(duration_ms, 4),
            "sdk_call_ms": round(sdk_call_ms, 4),
            "items_count": items_count,
        }),
    }
Deploy steps (IAM + DynamoDB + Lambda)
Terminal (setup)
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION="ap-northeast-1"
 
# Create DynamoDB table
aws dynamodb create-table \
  --table-name lambda-rust-bench \
  --attribute-definitions \
    AttributeName=pk,AttributeType=S \
    AttributeName=sk,AttributeType=S \
  --key-schema \
    AttributeName=pk,KeyType=HASH \
    AttributeName=sk,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --region $REGION
 
# Create IAM role
aws iam create-role --role-name lambda-rust-dynamodb-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name lambda-rust-dynamodb-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name lambda-rust-dynamodb-role \
  --policy-name dynamodb-bench-access \
  --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"dynamodb:PutItem\",\"dynamodb:GetItem\",\"dynamodb:Query\",\"dynamodb:BatchWriteItem\",\"dynamodb:DeleteItem\"],\"Resource\":\"arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/lambda-rust-bench\"}]}"
 
# Rust: build and deploy
cargo lambda build --release --arm64
cargo lambda deploy rust-dynamodb-bench \
  --iam-role arn:aws:iam::${ACCOUNT_ID}:role/lambda-rust-dynamodb-role \
  --region $REGION --memory 128 --timeout 30 \
  --env-vars TABLE_NAME=lambda-rust-bench
 
# Python: zip and deploy
zip -j function.zip lambda_function.py
aws lambda create-function \
  --function-name python-dynamodb-bench \
  --runtime python3.13 \
  --handler lambda_function.lambda_handler \
  --role arn:aws:iam::${ACCOUNT_ID}:role/lambda-rust-dynamodb-role \
  --zip-file fileb://function.zip \
  --memory-size 128 --architectures arm64 --timeout 30 \
  --environment "Variables={TABLE_NAME=lambda-rust-bench}" \
  --region $REGION
 
# Seed test data (25 items via BatchWriteItem)
aws lambda invoke --function-name rust-dynamodb-bench \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"batch_write"},"rawPath":"/","headers":{}}' \
  --region $REGION /tmp/seed.json

Cold Start: Impact of Adding the SDK

First, let's check how adding the SDK affects cold starts. Cold starts were forced by updating an environment variable, then measured with a GetItem operation.

Benchmark execution steps (cold start measurement)
Terminal (cold start measurement)
REGION="ap-northeast-1"
 
# Force cold start (updating env var recreates the execution environment)
aws lambda update-function-configuration \
  --function-name rust-dynamodb-bench \
  --environment "Variables={TABLE_NAME=lambda-rust-bench,BENCH_RUN=$(date +%s)}" \
  --region $REGION --output text --query 'FunctionName'
aws lambda wait function-updated \
  --function-name rust-dynamodb-bench --region $REGION
 
# 1st call (cold start) — get Init Duration / Duration from REPORT line
aws lambda invoke \
  --function-name rust-dynamodb-bench \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
  --region $REGION --log-type Tail \
  --query 'LogResult' --output text \
  /tmp/cold_1.json | base64 -d | grep REPORT
cat /tmp/cold_1.json
 
# 2nd call (connection reuse check)
aws lambda invoke \
  --function-name rust-dynamodb-bench \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
  --region $REGION --log-type Tail \
  --query 'LogResult' --output text \
  /tmp/cold_2.json | base64 -d | grep REPORT

For Python, use the same steps with --function-name python-dynamodb-bench and payload '{"queryStringParameters":{"op":"get"}}'.

MetricRustPython
Init Duration112 ms357 ms
First Duration912 ms291 ms
Billed Duration (total)1,025 ms648 ms
Max Memory Used30 MB90 MB

A surprising result. Init Duration is 3.2x faster for Rust (112ms vs 357ms), but the first DynamoDB call (Duration) is 3.1x slower for Rust (912ms vs 291ms). In total Billed Duration, Python is actually faster.

The cause is first-request initialization cost in the AWS SDK for Rust. The Rust SDK creates the client object during Init, but the underlying hyper HTTP client creates TCP connections on demand when a request is made. On the first request, DNS resolution, TCP connection establishment, and TLS handshake all happen at once, adding roughly 900ms of overhead.

Python's boto3 spends its Init phase on dynamic API construction (parsing JSON service definitions into Python classes), which is why its Init Duration is 357ms — much longer than Rust's 112ms. The 291ms first-request Duration also includes HTTP connection costs, but the impact is smaller than what we see with Rust.

This behavior was confirmed across 4 measurements (3 additional cold starts after the initial one).

RunInit Duration1st Duration2nd Duration
#1112 ms912 ms
#2112 ms920 ms3.3 ms
#3115 ms905 ms3.1 ms
#4110 ms916 ms2.9 ms

The key observation is that the second invocation immediately drops to 2-3ms. Once the HTTP connection pool is established, subsequent calls reuse the connection. Rust's cold start penalty is a "one-time cost" and should be evaluated separately from steady-state performance.

In Part 1, the SDK-free configuration had an Init Duration of 29ms. Adding the SDK increases this by ~80ms, attributable to aws-sdk-dynamodb + aws-config + hyper + rustls initialization. In practice, a ~1 second cold start is well within API Gateway's 29-second timeout.

Operation-Level Latency: How Much Does the 90x Gap Shrink?

Now for the main event. We execute four DynamoDB operations in warm-start state and compare Rust vs Python latency.

Framework: Two Operation Categories

DynamoDB operation latency consists of two components:

  1. DynamoDB-side processing — Network round-trip + DynamoDB engine processing. Language-independent
  2. Client-side processing — Request serialization, response deserialization, HTTP handling. This is where language performance differences show

The dominant component varies by operation pattern.

CategoryOperationsHypothesis
DynamoDB latency-dominantGetItem, PutItemSingle-item read/write. Client processing is light; DynamoDB round-trip dominates. Language gap should be small
Client processing-dominantQuery (25 items), BatchWriteItem (25 items)Multiple items require serialization/deserialization. Client-side ratio increases; language gap should be larger

The 25 items for Query come from the test data seeded beforehand. The 25 items for BatchWriteItem is the API's maximum limit, chosen to maximize serialization volume and measure the most pronounced language gap. In practice, the gap is expected to narrow with fewer items.

Let's test this hypothesis.

Results

Measured sdk_call_ms (application-level timing) across 5 warm-start invocations per operation.

Benchmark execution steps (warm start measurement)
Terminal (warm start measurement)
REGION="ap-northeast-1"
 
# Warm up (discard first call)
aws lambda invoke \
  --function-name rust-dynamodb-bench \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
  --region $REGION /tmp/warmup.json > /dev/null 2>&1
 
# 5 measurement runs (change op to get / put / query / batch_write)
for i in $(seq 1 5); do
  aws lambda invoke \
    --function-name rust-dynamodb-bench \
    --cli-binary-format raw-in-base64-out \
    --payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
    --region $REGION --log-type Tail \
    --query 'LogResult' --output text \
    /tmp/warm_${i}.json | base64 -d | grep REPORT
  cat /tmp/warm_${i}.json
done

For Python, use the same steps with --function-name python-dynamodb-bench and payload '{"queryStringParameters":{"op":"get"}}'.

OperationRust sdk_call_msPython sdk_call_msRatio
GetItem2.9 ms13.8 ms4.7x
PutItem3.6 ms13.3 ms3.6x
Query (25 items)2.6 ms16.2 ms6.3x
BatchWriteItem (25 items)6.0 ms20.3 ms3.4x

sdk_call_ms is the pure SDK call time measured within the application code. Lambda's Duration, on the other hand, includes runtime overhead such as the event loop and response serialization. Use sdk_call_ms to compare SDK performance between languages; use Duration for the value that affects actual billing.

OperationRust DurationPython DurationRatio
GetItem6.7 ms34.5 ms5.1x
PutItem7.0 ms26.4 ms3.8x
Query (25 items)9.7 ms35.9 ms3.7x
BatchWriteItem (25 items)13.0 ms40.1 ms3.1x

Memory usage was consistently Rust 30MB / Python 90MB across all operations.

Breakdown Analysis

Comparing against our hypothesis:

DynamoDB latency-dominant (GetItem / PutItem):

The hypothesis predicted "small language gap (1.5-2x)," but actual measurements showed 3.6-4.7x. The gap was larger than expected.

One likely reason is that boto3's overhead was larger than anticipated. boto3 performs type checking, validation, and other processing when converting DynamoDB responses to Python dicts. Rust's serde resolves type information at compile time, making deserialization significantly faster. The sdk_call_ms gap (roughly 11ms for GetItem) is likely attributable to this difference in client-side processing efficiency.

Client processing-dominant (Query / BatchWriteItem):

Query at 6.3x, BatchWriteItem at 3.4x. Query shows a larger gap likely because deserializing 25 response items increases client-side processing volume, amplifying the efficiency difference between languages.

BatchWriteItem's primary processing is serializing 25 items (request construction), but DynamoDB's write processing is also part of the total time. This likely reduces the proportion of client-side processing relative to Query, resulting in a smaller ratio.

90x → 3-6x: Where Did the Gap Go?

The Part 1 benchmark used CPU workload (Fibonacci calculation) and memory workload (100K-element vector allocation). These are pure client-side processing where Rust's zero-cost abstractions and native code execution have maximum advantage.

With DynamoDB operations, a significant portion of processing time is likely spent on network round-trips (Lambda → DynamoDB endpoint). This portion is language-independent. Rust can only speed up serialization/deserialization and HTTP handling, which represent a smaller fraction of total time — so the ratio naturally compresses.

That said, 3-6x is far from negligible. For 1 million monthly invocations, the Billed Duration difference translates directly to cost savings.

Discussion: When to Choose Rust Lambda + DynamoDB

What the Data Shows

  • Rust is 3-6x faster across all operation patterns. "The gap doesn't disappear even in DynamoDB latency-dominant operations" is this article's key finding
  • Query (multi-item retrieval) shows the largest gap (6.3x). The gap scales with deserialization volume
  • Cold start is slower for Rust (total 1,025ms vs 648ms). However, the second invocation immediately stabilizes at 2-3ms
  • Memory usage is consistently 3x more efficient for Rust (30MB vs 90MB)

Considerations Beyond the Data

The following factors can't be derived from benchmark data alone but affect adoption decisions.

Invocation frequency and cost: With 128MB / arm64 Tokyo region pricing and 1 million monthly invocations, the Billed Duration difference (Rust avg 10ms vs Python avg 35ms) translates directly to compute cost. The higher the frequency, the greater Rust's economic advantage.

Team Rust proficiency: Lambda functions are relatively small codebases, making them a reasonable scope for Rust adoption. However, the learning curve for ownership and lifetimes is real. Without existing Rust experience on the team, expect initial development velocity to be several times slower than Python.

Summary

  • The 90x gap shrinks to 3-6x but doesn't disappear — Even with DynamoDB network round-trips dominating, Rust's advantage in SDK serialization/deserialization persists
  • The gap varies by operation pattern — Query (multi-item retrieval) at 6.3x, single operations at 3.6-4.7x. The gap scales with deserialization volume
  • Cold start should be evaluated as a "one-time penalty" — Rust's first request takes 912ms for TLS connection establishment, but the second invocation immediately stabilizes at 2-3ms
  • Memory efficiency is consistently 3x better for Rust — 30MB vs 90MB. Rust runs comfortably even at the minimum 128MB memory setting

Cleanup

Resource deletion commands
Terminal
REGION="ap-northeast-1"
 
# Delete Lambda functions
aws lambda delete-function --function-name rust-dynamodb-bench --region $REGION
aws lambda delete-function --function-name python-dynamodb-bench --region $REGION
 
# Delete DynamoDB table
aws dynamodb delete-table --table-name lambda-rust-bench --region $REGION
 
# Delete IAM role
aws iam delete-role-policy --role-name lambda-rust-dynamodb-role \
  --policy-name dynamodb-bench-access
aws iam detach-role-policy --role-name lambda-rust-dynamodb-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lambda-rust-dynamodb-role

Share this post

Shinya Tahara

Shinya Tahara

Solutions Architect @ AWS

I'm a Solutions Architect at AWS, providing technical guidance primarily to financial industry customers. I share learnings about cloud architecture and AI/ML on this site.The views and opinions expressed on this site are my own and do not represent the official positions of my employer.

Related Posts