If you come from a .NET background, AWS Lambda can feel unfamiliar at
first — there's no Program.cs hosting a web server, no
IIS, no long-running process. Instead, your code runs only when
triggered, for as long as it takes to finish, and then stops. This
guide walks through building, testing, and deploying a real C# Lambda
function, along with the .NET-specific details — cold starts, Native
AOT, IAM, and cost — that matter once you move past "Hello World."
1. What AWS Lambda Actually Runs
A Lambda function is a single handler method that AWS invokes with an event payload and a context object, runs inside a managed execution environment, and then either keeps warm for the next invocation or gets torn down. For .NET, AWS maintains managed runtimes for .NET 6 and .NET 8, and also supports .NET 8 Native AOT, which compiles your function to a native executable instead of running on the standard CLR — this matters a lot for cold start time, covered in section 6.
2. Setting Up Your Project
Install the Lambda tooling for the .NET CLI once, globally:
dotnet tool install -g Amazon.Lambda.Tools
Then scaffold a new function project:
dotnet new lambda.EmptyFunction --name OrderProcessor.Lambda cd OrderProcessor.Lambda/src/OrderProcessor.Lambda
This template gives you a Function.cs file, a
.csproj, and an aws-lambda-tools-defaults.json
file that stores your deployment settings so you don't have to
retype them every time.
3. The Function Handler
Here's a realistic handler — not just string-echo, but one that parses a JSON payload, validates it, and returns a structured response, which is closer to what you'll actually ship:
using Amazon.Lambda.Core;
using System.Text.Json;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace OrderProcessor.Lambda;
public record OrderRequest(string OrderId, decimal Amount, string CustomerEmail);
public record OrderResponse(bool Success, string Message);
public class Function
{
public OrderResponse FunctionHandler(OrderRequest request, ILambdaContext context)
{
context.Logger.LogInformation($"Processing order {request.OrderId} for {request.CustomerEmail}");
if (request.Amount <= 0)
{
return new OrderResponse(false, "Order amount must be greater than zero.");
}
// Business logic goes here — save to DynamoDB, call an API, publish to SNS, etc.
return new OrderResponse(true, $"Order {request.OrderId} processed successfully.");
}
}
Two things worth calling out for .NET developers specifically:
ILambdaContext replaces the ASP.NET
HttpContext you're used to — it gives you the remaining
execution time, request ID, and a logger, but nothing about HTTP
unless you're behind API Gateway. And the
[assembly: LambdaSerializer] attribute is easy to forget;
without it, Lambda won't know how to deserialize your event into
OrderRequest.
4. Deploying the Function
Deploy directly from the CLI without touching the AWS Console:
dotnet lambda deploy-function OrderProcessor-Prod \ --function-role OrderProcessorLambdaRole
The tool will prompt for a few settings the first time (memory,
timeout, IAM role) and then save them into
aws-lambda-tools-defaults.json so future deploys are a
single command:
dotnet lambda deploy-function
5. Testing Without Deploying Every Time
Redeploying for every small change is slow. Test locally first with a sample event file:
dotnet lambda invoke-function OrderProcessor-Prod --payload "{ \"OrderId\": \"1001\", \"Amount\": 49.99, \"CustomerEmail\": \"test@example.com\" }"
For faster local iteration without even hitting AWS, the
Amazon.Lambda.TestTool package spins up a local Lambda
runtime emulator you can debug directly from Visual Studio with
breakpoints — genuinely useful once your handler grows past a few
lines.
6. Cold Starts and Native AOT
This is the part most .NET-to-Lambda guides skip, and it's the part that actually matters in production. A standard .NET 8 Lambda function running on the managed CLR runtime typically has a cold start in the 150–400ms range depending on package size and dependency injection setup. That's usually fine for background jobs, but it's noticeable if the function sits behind API Gateway serving user-facing requests.
.NET 8's Native AOT support for Lambda compiles your function ahead of time into a native binary, which cuts cold starts down significantly — often into double digits (sub-100ms) — because there's no JIT compilation happening on first invocation. The trade-off: Native AOT has stricter reflection limitations, so libraries that rely heavily on runtime reflection (some serialization or DI containers) may need adjustment. To use it:
dotnet new lambda.NativeAOT --name OrderProcessor.Lambda
If your function is latency-sensitive and API Gateway-facing, start with Native AOT from day one rather than migrating later — the DI and serialization adjustments are much easier to make before the codebase grows.
7. IAM: The Part That Actually Blocks You
The most common "it works locally but not in Lambda" issue for developers new to AWS isn't code — it's permissions. Your function's execution role needs explicit permission for everything it touches. A function writing to DynamoDB and reading from S3 needs a policy like this attached to its execution role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Orders"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::order-attachments/*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
That last block — CloudWatch Logs permissions — is easy to forget and is the reason a function can "run successfully" while you see absolutely nothing in the logs to debug it with.
8. Configuration and Environment Variables
Skip hardcoded connection strings. Set environment variables in
aws-lambda-tools-defaults.json or the console, and read
them the same way you would in any .NET app:
var tableName = Environment.GetEnvironmentVariable("ORDERS_TABLE_NAME")
?? throw new InvalidOperationException("ORDERS_TABLE_NAME is not configured.");
For anything sensitive — API keys, connection strings with
credentials — use AWS Secrets Manager or Parameter Store instead of
plain environment variables, and grant the execution role
secretsmanager:GetSecretValue scoped to just that
secret's ARN.
9. Triggering From API Gateway
To expose the function as an HTTP endpoint, change the handler signature to accept an API Gateway proxy request instead of your own custom type:
using Amazon.Lambda.APIGatewayEvents;
public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest request, ILambdaContext context)
{
var order = JsonSerializer.Deserialize<OrderRequest>(request.Body);
return new APIGatewayProxyResponse
{
StatusCode = 200,
Body = JsonSerializer.Serialize(new { success = true }),
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
10. What This Actually Costs
Lambda pricing is based on requests and execution duration (GB-seconds — memory allocated multiplied by execution time). A function with 256MB memory that runs for 200ms, called 1 million times a month, costs roughly $0.83 in compute plus $0.20 per million requests — a few dollars a month for moderate traffic. The number that surprises people isn't Lambda itself; it's forgetting that a Lambda function inside a VPC needs a NAT Gateway for outbound internet access, and NAT Gateway's hourly cost can dwarf the Lambda bill for a low-traffic function. If your function doesn't need VPC access (no RDS, no internal-only resources), skip the VPC entirely and avoid that cost.
Common Mistakes .NET Developers Make With Lambda
-
Injecting a fresh
HttpClientper invocation instead of a static/singleton one — this exhausts sockets under load exactly like it would in ASP.NET, just less visibly. - Expecting static fields to reset between invocations. They don't — Lambda reuses warm execution environments, so static state persists across calls until the environment is recycled. Useful for caching, dangerous if you assumed a clean slate every time.
- Setting the timeout too high "to be safe." A stuck function now costs money for the full timeout duration on every failure instead of failing fast.
-
Forgetting that Lambda's
/tmpdirectory is the only writable local storage, and it's capped (512MB by default, up to 10GB configurable) and not guaranteed to persist between invocations.
Final Thoughts
Lambda isn't a replacement for ASP.NET Core in every scenario — for a full API with dozens of endpoints, a container on ECS or a traditional API is often simpler to reason about. Where Lambda earns its place is event-driven work: processing an S3 upload, reacting to a DynamoDB stream, running a scheduled job, or handling bursty, infrequent traffic where paying only per invocation beats running a server around the clock. Start with the managed .NET 8 runtime to get comfortable with the model, then move to Native AOT once latency actually matters for your use case.