Deploying Rust to AWS Lambda With Cargo Lambda Tooling
Build and deploy a Rust function to AWS Lambda with cargo-lambda: project template, lambda_runtime handler, ARM builds, deploy, invoke locally, cold starts and limits.

AWS Lambda runs a function when an event arrives, bills by the millisecond, and costs nothing while idle. Rust suits that model unusually well. A Rust binary starts in a few milliseconds, uses little memory, and has no virtual machine to warm up, which are the three things Lambda charges for. There is no official Rust runtime in the managed list, so the function ships as a native executable on a custom runtime, and cargo lambda is the tool that builds it, runs it locally, and uploads it.
This guide builds two functions: one that handles a JSON event, and one that handles an HTTP request, tests both locally the way Lambda would call them, and measures what the build produces. Everything ran on Rust 1.99, edition 2024, with lambda_runtime 1.5.0, lambda_http and cargo-lambda 1.9.2 on macOS. Nothing was deployed to AWS: that needs an account and credentials, so the deploy commands near the end are the documented ones and are marked as not run. Everything before them, including the local invocations and their outputs, was executed.
How Lambda runs a native binary
A function runs on the provided.al2023 custom runtime on x86-64 or ARM64. Lambda starts your executable, which must be named bootstrap, and your program polls a local HTTP endpoint (the Runtime API) for the next event, runs your handler, and posts the result back. The lambda_runtime crate implements that loop; you write an async function from an event type to a response type, wrap it with service_fn, and call run. The program stays alive between invocations, so work done outside the handler happens once per cold start, and work inside it happens every time. That distinction is the main performance lever.
A function that takes JSON
use lambda_runtime::{Error, LambdaEvent, service_fn};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Request {
order_id: u64,
items: Vec<Item>,
}
#[derive(Deserialize)]
struct Item {
sku: String,
cents: u32,
qty: u32,
}
#[derive(Serialize)]
struct Response {
order_id: u64,
total_cents: u64,
lines: usize,
}
async fn handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
let (request, context) = event.into_parts();
if request.items.is_empty() {
return Err("an order needs at least one item".into());
}
let total = request.items.iter().map(|i| u64::from(i.cents) * u64::from(i.qty)).sum();
println!("request {} priced order {}", context.request_id, request.order_id);
let _ = request.items.iter().map(|i| &i.sku).count();
Ok(Response { order_id: request.order_id, total_cents: total, lines: request.items.len() })
}
#[tokio::main]
async fn main() -> Result<(), Error> {
lambda_runtime::run(service_fn(handler)).await
}The event type Request is an ordinary struct with serde::Deserialize. Lambda passes the incoming JSON, lambda_runtime deserializes it, and the handler receives it wrapped in LambdaEvent, which also carries the invocation context: request ID, deadline and the function's ARN. The handler computes the order total, validates that the order is not empty and returns a Response that is serialized back to JSON. An error returned as Err becomes a failed invocation that Lambda records and reports.
Test it locally before it goes anywhere
cargo lambda watch runs a local emulator of the Runtime API, rebuilds on file changes and exposes an invoke endpoint. cargo lambda invoke sends it an event. Three invocations show the three outcomes:
cargo lambda watch --invoke-port 19000
cargo lambda invoke orders --invoke-port 19000 --data-ascii '{"order_id": 42, "items": [{"sku":"book","cents":3999,"qty":1},{"sku":"sticker","cents":299,"qty":4}]}'{"order_id":42,"total_cents":5195,"lines":2}The total is 3,999 plus 4 times 299, which is 5,195 cents. The function's println! went to the emulator's log with the request ID: request 1eb0a099-... priced order 42. In Lambda that line lands in CloudWatch Logs.
An order with no items hits the validation branch and fails the invocation with the message from the handler:
Error: alloc::boxed::Box<dyn core::error::Error + core::marker::Send + core::marker::Sync>
× an order needs at least one itemAnd an event with the wrong shape never reaches the handler at all:
Error: lambda_runtime::deserializer::DeserializeError
× failed to deserialize the incoming data into the function's payload type:
│ [order_id] order_id: invalid type: string "x", expected u64 at line 1
│ column 16The second message is worth studying because it is what production will show for a malformed event. The runtime reports the field name, the type it expected and the position, and it fails the invocation before your code runs. Take that as a design rule: put validation in types, so impossible events are rejected by deserialization, and keep the handler for the business logic. The first error is a Box<dyn Error>, which is serviceable for a demonstration. For a real function, define an error type with thiserror so that failures have names you can alarm on.
A function behind an HTTP endpoint
Many Lambda functions sit behind a function URL or API Gateway. The lambda_http crate adapts those events into http::Request and http::Response, so the handler looks like a web handler and can be tested without any AWS types:
use lambda_http::{Body, Error, Request, RequestExt, Response, run, service_fn};
use serde::Serialize;
#[derive(Serialize)]
struct Greeting {
message: String,
}
async fn handler(event: Request) -> Result<Response<Body>, Error> {
let name = event.query_string_parameters_ref().and_then(|q| q.first("name")).unwrap_or("world").to_string();
if name.len() > 40 {
return Ok(Response::builder().status(400).body(Body::from("name is too long"))?);
}
let body = serde_json::to_string(&Greeting { message: format!("hello, {name}") })?;
Ok(Response::builder().status(200).header("content-type", "application/json").body(Body::from(body))?)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
run(service_fn(handler)).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn request(name: Option<&str>) -> Request {
let mut params = HashMap::new();
if let Some(n) = name {
params.insert("name".to_string(), vec![n.to_string()]);
}
Request::default().with_query_string_parameters(params)
}
#[tokio::test]
async fn greets_by_name() {
let res = handler(request(Some("ferris"))).await.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.body(), &Body::from(r#"{"message":"hello, ferris"}"#));
}
#[tokio::test]
async fn defaults_to_world() {
let res = handler(request(None)).await.unwrap();
assert!(String::from_utf8_lossy(res.body().as_ref()).contains("hello, world"));
}
#[tokio::test]
async fn rejects_oversized_names_with_400() {
let res = handler(request(Some(&"x".repeat(41)))).await.unwrap();
assert_eq!(res.status(), 400);
}
}The handler reads a name query parameter, defaults to world, rejects names longer than 40 characters with a 400, and returns JSON. The unit tests at the bottom construct a request directly, with no AWS service, and run it through the handler. They pass:
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sRun through the same local emulator, an HTTP call returned the JSON body:
$ curl -i "localhost:19001/?name=ada"
HTTP/1.1 200 OK
content-type: application/json
content-length: 24
{"message":"hello, ada"}
$ curl -o /dev/null -w "long name -> HTTP %{http_code}\n" "localhost:19001/?name=xxxxxxxx..."
long name -> HTTP 400The headers with access-control-* that appeared in the full response were added by the emulator; your deployed function sets its own. Testing at this level, handler in, response out, with a real emulator on top, catches nearly everything short of IAM and networking.
Build for Lambda
cargo lambda build produces the bootstrap binary. Cross-compiling to Linux is handled for you by cargo-zigbuild, so no Linux machine or container is needed. ARM64 is cheaper per millisecond on Lambda and the Rust code is the same, so build for it unless a dependency forbids:
cargo lambda build --release --arm64
cargo lambda build --release --arm64 --output-format zip1141632 target/lambda/orders/bootstrap
542554 target/lambda/orders/bootstrap.zip
target/lambda/orders/bootstrap: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1The executable is 1.14 MB and the deployment zip is 542 KB. Lambda's limit for a zip is 50 MB (250 MB unzipped), so a Rust function is far below it, and a small package unpacks faster on a cold start. Note the file type: dynamically linked against glibc, which is what the provided.al2023 runtime provides. If you build with a musl target instead, the binary is static and does not depend on the runtime's libc, which can matter for portability, at the allocator cost described in shipping Rust to production.
Local invocation of the warm emulator, wall time including the CLI's own startup, was about 0.17 seconds. That number is dominated by the cargo lambda client and says little about the function. For cold start and duration, measure in AWS, and look at the REPORT line Lambda writes for each invocation, which gives duration, billed duration, memory size, peak memory and, on a cold start, init duration.
What to do outside the handler
Anything expensive and reusable belongs in main before run, because it executes once per cold start and is then shared by every invocation on that instance. That includes loading configuration, building an HTTP client, creating a database pool and parsing static data. The handler receives these through a closure or a shared Arc:
#[tokio::main]
async fn main() -> Result<(), Error> {
let client = reqwest::Client::new(); // built once per cold start
let config = std::sync::Arc::new(Config::from_env()?);
run(service_fn(|event| handler(event, client.clone(), config.clone()))).await
}The reverse is also true: do not keep per-request state in globals, because an instance may handle many requests in sequence, and a later one must not see an earlier one's data. Lambda freezes the instance between invocations, so background tasks you spawned do not run while it is idle; finish your work before returning.
Sizing memory and concurrency
Lambda allocates CPU in proportion to memory, so the setting is a speed knob as well as a limit. A Rust function that uses 20 MB may still run faster at 512 MB than at 128 MB, because the larger setting gets more CPU, and the cost per request can drop because it finishes sooner. The way to choose is to invoke it at several memory sizes under a representative load and compare billed cost, which is memory times duration. Each concurrent request runs in its own instance, so one instance never handles two requests at once; there is no thread-safety concern between invocations, though your code still needs to be correct if you spawn tasks inside one. Set a reserved concurrency limit if your function calls a downstream system that cannot take unlimited parallel load, such as a database with a connection cap, and use an RDS proxy or a pool-per-instance of one connection to avoid exhausting it.
Queue-triggered functions and partial batch failure
Many Lambda functions are not HTTP at all: a queue delivers messages in batches and the function processes each one. This is where Rust's typed errors and cheap invocations pay off, and it is also where a naive handler does real damage. If the handler returns an error for a batch of ten messages because one is bad, Lambda treats the whole batch as failed, and all ten go back to the queue, so nine good messages are processed again. The fix is a partial batch response: report only the messages that failed, and Lambda deletes the rest.
The event types come from the aws_lambda_events crate, with only the sqs feature enabled to keep compile time and size down. Errors are a small enum with thiserror, so each failure mode has a name and a message:
use aws_lambda_events::event::sqs::{BatchItemFailure, SqsBatchResponse, SqsEvent};
use lambda_runtime::{Error, LambdaEvent, service_fn};
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
enum JobError {
#[error("body is not valid json: {0}")]
Parse(#[from] serde_json::Error),
#[error("amount must be positive, got {0}")]
BadAmount(i64),
}
#[derive(Deserialize)]
struct Job {
amount: i64,
}
fn process(body: &str) -> Result<i64, JobError> {
let job: Job = serde_json::from_str(body)?;
if job.amount <= 0 {
return Err(JobError::BadAmount(job.amount));
}
Ok(job.amount * 2)
}
async fn handler(event: LambdaEvent<SqsEvent>) -> Result<SqsBatchResponse, Error> {
let mut failures = Vec::new();
for record in event.payload.records {
let id = record.message_id.clone().unwrap_or_default();
match process(record.body.as_deref().unwrap_or("")) {
Ok(v) => println!("{id} ok {v}"),
Err(e) => {
println!("{id} failed: {e}");
let mut f = BatchItemFailure::default();
f.item_identifier = id;
failures.push(f);
}
}
}
let mut resp = SqsBatchResponse::default();
resp.batch_item_failures = failures;
Ok(resp)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
lambda_runtime::run(service_fn(handler)).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn good() { assert_eq!(process(r#"{"amount":21}"#).unwrap(), 42); }
#[test]
fn bad_amount() { assert_eq!(process(r#"{"amount":-3}"#).unwrap_err().to_string(), "amount must be positive, got -3"); }
#[test]
fn bad_json() { assert!(process("nope").unwrap_err().to_string().starts_with("body is not valid json")); }
}The first draft of this handler did not compile, and the message is instructive:
error[E0639]: cannot create non-exhaustive struct using struct expression
--> src/main.rs:34:31
|
34 | failures.push(BatchItemFailure { item_identifier: id });
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^The event structs are marked #[non_exhaustive] so the crate can add fields without a breaking release. From outside the crate you cannot build one with a struct expression; you create it with Default::default() and assign fields. The version above does that. It is a small thing, but it will meet you on nearly every type in the crate, so expect it.
The three unit tests exercise process, a plain function from a string to a result, with no Lambda types involved. That separation is deliberate: keep the logic in functions that know nothing about Lambda, and let the handler be a thin adapter that decodes the event and encodes the response. The tests passed:
running 3 tests
test tests::bad_amount ... ok
test tests::good ... ok
test tests::bad_json ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThen a two-message batch through the local emulator: one valid message, one with a negative amount.
$ cargo lambda invoke sqsjobs --invoke-port 19002 --data-file ev.json
{"batchItemFailures":[{"itemIdentifier":"m2"}]}
# emulator log
m1 ok 42
m2 failed: amount must be positive, got -3Only m2 is reported as failed. In AWS, Lambda deletes m1 from the queue and leaves m2 to be retried, and after the queue's maxReceiveCount it moves to the dead-letter queue. One setting is needed for this to work: the event source mapping must have ReportBatchItemFailures enabled in its FunctionResponseTypes. Without it, Lambda ignores the response body and treats a successful return as the whole batch succeeding, which silently drops the failed message. That configuration was not exercised here, because it lives in the AWS account, but it is documented behavior and the most common mistake with this pattern.
Because a message can be delivered more than once, even in the best case, process must be idempotent. Standard SQS queues guarantee at-least-once delivery, so a retried or duplicate message must not charge a card twice. Store a processed-message marker, or make the write itself idempotent with a unique key derived from the message ID, and treat a duplicate as success.
Measuring cold starts honestly
People quote Rust cold starts of a few milliseconds, and in the REPORT line the figure is real, but it depends on what happens during init. Init covers loading the binary, running main up to run, and anything you construct there. A 1.1 MB binary loads quickly; an AWS SDK client that fetches credentials and configuration is the usual cost, along with TLS setup and parsing large static data. The way to measure it is the Init Duration field in the REPORT line of a cold invocation, not a local timing, and the way to compare two changes is to force cold starts by updating an environment variable on the function, which creates a fresh instance on the next call, then invoking it a number of times and taking the median rather than the best case.
Three changes usually help, in the order of payoff. Lazy-initialize clients only when a code path needs them, so an invocation that never calls S3 never builds an S3 client. Build with opt-level tuned for size or speed deliberately, and enable lto = "thin" and codegen-units = 1 in the release profile, which shrinks the binary and often speeds it. And choose ARM64 on Graviton, which is cheaper per millisecond and generally as fast or faster for this kind of work. None of that needs guessing: change one thing, redeploy, force cold starts, compare medians. This guide could not do that comparison, since no account was available, so treat any specific cold-start number you read, including numbers from blog posts, as belonging to someone else's function and region.
The release profile for functions
The build settings that matter are the same ones that apply to any small Rust binary, and they carry over to Lambda because package size and init time scale with the binary:
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = truepanic = "abort" removes the unwinding machinery and makes a panic terminate the process immediately; Lambda then reports the invocation as failed and starts a fresh instance for the next one. That is acceptable for a function, since a panicked instance should not be reused anyway, but it means you cannot use catch_unwind to keep going after a panic. strip = true drops symbols, which makes panic backtraces less useful in the logs, so keep symbols in a staging build and strip the production one. The cargo lambda build command respects the profile, so there is no separate Lambda configuration to maintain.
A checklist before the first deploy
Before pointing real traffic at a function, confirm a short list. The function has its own role with only the permissions it needs. The timeout is set to a realistic value, and the memory size has been picked by measurement. Logging is structured and reaches CloudWatch with a retention period set, because the default keeps logs forever and bills for it. Errors and throttles have alarms. Asynchronous and queue triggers have dead-letter handling and idempotent handlers. The HTTP endpoint is authenticated, and its input is validated by types. Secrets come from the environment through Secrets Manager or Parameter Store, never from the binary, and are read once outside the handler. And the unit tests run in CI against the handler functions, with the emulator used for a smoke test, so a change is checked before it ships.
Deploying (commands not run here)
Deployment needs an AWS account and credentials in the environment or profile, so these commands were not executed in this guide. They are the documented flow. Create an execution role with the basic logging policy once, then deploy:
cargo lambda build --release --arm64
cargo lambda deploy orders --iam-role arn:aws:iam::123456789012:role/orders-lambda-role
cargo lambda deploy orders-http --enable-function-urlThe first deploy creates the function, and later ones update the code. --enable-function-url gives the HTTP function a public URL, which you should protect with authentication (AWS_IAM or your own check) before exposing anything real. Afterward, invoke the deployed function with cargo lambda invoke --remote orders --data-ascii '{...}' and read the REPORT line. Put the function and its role in infrastructure code (Terraform, CDK or SAM) once the shape settles, so the configuration is reviewed and reproducible. The role grants the minimum: logging, and the specific resources the function touches.
Observability and failures
Write logs with tracing and a JSON subscriber; Lambda forwards standard output to CloudWatch, and structured fields make queries possible. Alarm on the function's Errors and Throttles metrics, and set a timeout slightly above your slowest expected invocation, not the 15-minute maximum, so a hung call fails fast. Configure a dead-letter queue or on-failure destination for asynchronous invocations, because Lambda retries them twice by default, and your handler must therefore be idempotent, the same discipline as in background jobs with retries.
When Lambda is the wrong shape
Lambda is a poor fit for sustained high throughput, where a container that stays busy is cheaper per request. It is awkward for long-lived connections such as websockets, for work longer than 15 minutes, and for services with heavy initialization that cannot be amortized. And it ties you to one cloud's eventing model. For a steady service, deploying to Fly.io or running on Kubernetes is usually the better fit; for spiky, event-driven work, Lambda and a 1 MB Rust binary are hard to beat.


