The cold start problem and why Go wins
You finish a Go HTTP handler. It responds in two milliseconds locally. You want it on AWS Lambda so you only pay when requests hit. The first hurdle is that Lambda runs on Amazon Linux, not your Mac or Windows machine. Go solves this by design. The compiler targets any OS and architecture combination without needing a cross-compiler toolchain. You just set two environment variables and run go build. The result is a single static binary that carries its own dependencies. No dependency folder to zip. No module cache to copy. Just one file.
Lambda charges by the millisecond and by memory allocation. Cold starts happen when AWS boots a fresh container for your first request after a period of inactivity. Interpreted languages spend those first hundred milliseconds downloading packages, compiling just-in-time, or initializing runtimes. Go spends them loading a precompiled binary into memory. The difference is measurable. A Go Lambda often responds in under fifty milliseconds on a cold start. That speed comes from the compiler doing the heavy lifting ahead of time.
Go binaries are statically linked by default. The standard library and your code are baked into one executable. You do not need to ship a go.mod or a vendor directory to the cloud. You ship the binary. That simplicity is the foundation of serverless Go.
How Lambda actually runs your code
Think of Lambda as a vending machine that accepts custom machines. You do not hand it source code to compile. You hand it a finished appliance. AWS provides the electricity and the shelf space. Your job is to build an appliance that plugs into their standard socket. That socket is the Lambda runtime interface.
When you choose the provided.al2 runtime, you are telling AWS: I will supply the executable. You just run it and forward events. The al2 stands for Amazon Linux 2. The provided part is a legacy name that means customer-provided runtime. Lambda boots a container, executes your binary, and expects it to listen for events. Modern Lambda functions receive events through a Unix domain socket. Older versions used environment variables. The AWS SDK for Go abstracts that plumbing away, but the underlying contract remains the same: your process starts, initializes, and blocks while waiting for work.
Convention aside: the provided.al2 runtime expects your handler to be an executable named exactly what you specify in the handler field. If you name your binary main, your handler must be main. If you name it api, your handler must be api. AWS does not guess. Match the name or the function fails to initialize.
Compile for a machine you don't own
Cross-compilation in Go is a single command. You tell the compiler which operating system and CPU architecture to target, then run the build. AWS Lambda runs on Amazon Linux. The most cost-effective and performant architecture is ARM64, powered by AWS Graviton processors. You target it by setting GOOS and GOARCH.
Here is the simplest Lambda handler using the official AWS SDK for Go v2:
package main
import (
"context"
"log"
"net/http"
"github.com/aws/aws-lambda-go/lambda"
)
// Handler responds to API Gateway HTTP events
func Handler(ctx context.Context, r *http.Request) (string, error) {
// context carries deadline and cancellation signals
// request contains the original HTTP method, path, and headers
return "Hello from Go on Lambda", nil
}
func main() {
// lambda.Start wires the handler to the Lambda runtime socket
// it blocks until the container is torn down
lambda.Start(Handler)
}
Convention aside: context.Context always goes as the first parameter in Go functions that can be cancelled. The Lambda SDK passes it automatically. Respect it. Check ctx.Err() if you spawn background work.
Compile it for AWS:
# target Amazon Linux on ARM64 Graviton CPUs
GOOS=linux GOARCH=arm64 go build -o main main.go
The GOOS=linux flag tells the compiler to generate ELF binaries instead of macOS Mach-O or Windows PE files. The GOARCH=arm64 flag targets the 64-bit ARM instruction set. AWS Graviton instances run this architecture natively. You get better performance per dollar compared to x86. The -o main flag names the output file. That name must match your Lambda handler configuration later.
If your code uses C libraries, the default build will fail on Lambda because Amazon Linux does not ship the same C headers as your local machine. Force pure Go compilation by disabling CGO:
# disable C interop to guarantee a fully static binary
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o main main.go
Convention aside: CGO_ENABLED=0 is the standard practice for serverless Go. It removes the dependency on libc and ensures your binary runs on any Linux environment without shared library mismatches.
Package and push
Lambda does not accept raw binaries. It expects a ZIP archive. The archive must contain your executable at the root level. If you nest it in a folder, the runtime cannot find it. Zip the file directly:
# compress the binary into a deployment package
# keep the executable at the root of the archive
zip function.zip main
Upload the package using the AWS CLI. You need an IAM role that grants Lambda permission to write logs and access other AWS services. Replace the ARN with your actual execution role:
# create the function with the customer-provided runtime
# handler must match the binary name exactly
aws lambda create-function \
--function-name my-go-function \
--runtime provided.al2 \
--role arn:aws:iam::123456789012:role/lambda-execution-role \
--handler main \
--zip-file fileb://function.zip
The --runtime provided.al2 flag tells AWS to skip its managed Go runtime and execute your binary directly. The --handler main flag points to the executable name inside the ZIP. The fileb:// prefix tells the CLI to read the file as binary data. AWS validates the archive, uploads it to S3 internally, and provisions the function.
Convention aside: gofmt is mandatory in Go projects. Run it before committing. Most editors format on save. It keeps your codebase consistent and removes formatting debates from code reviews. Trust the tool.
When things go sideways
Deployment failures usually fall into three categories: architecture mismatch, handler naming errors, or missing IAM permissions.
If you compile for amd64 but deploy to an ARM64 Lambda, the runtime refuses to start. You will see runtime: failed to create new OS thread in CloudWatch logs. The container boots, tries to execute the binary, and the Linux kernel rejects the instruction set. Always verify your GOARCH matches the Lambda architecture setting.
If your handler name does not match the binary, AWS returns handler main not found. The runtime scans the ZIP root for an executable with that exact name. Case matters. Main and main are different files on Linux. Double check the -o flag and the --handler flag.
If your IAM role lacks logs:CreateLogGroup and logs:CreateLogStream, the function deploys but fails on the first invocation. Lambda cannot write initialization logs. You get a generic Unhandled error with no stack trace. Attach the AWSLambdaBasicExecutionRole managed policy to your role. It grants the minimum logging permissions required.
Debugging Lambda locally requires simulating the runtime environment. The AWS SAM CLI provides sam local invoke, which runs your binary inside a Docker container that mimics Lambda. You can also test the binary directly on Linux using docker run --rm -v $(pwd):/app -w /app public.ecr.aws/sam/build-go1.x:latest ./main. This catches architecture and dependency issues before you push to AWS.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors in Lambda handlers. Return them. The runtime converts them to HTTP 500 responses or CloudWatch error logs.
The worst Lambda bug is the one that never logs. Always log initialization steps. Always return errors from your handler. Always check CloudWatch metrics for Duration and InitDuration.
Pick your deployment path
Use a static binary with provided.al2 when you want the smallest package size and fastest cold starts. Use a container image when your binary exceeds 250 MB or you need custom system libraries that Amazon Linux does not provide. Use the AWS SAM CLI when you want infrastructure-as-code and local testing in one workflow. Use a managed service like API Gateway plus Lambda when you need automatic HTTPS, routing, and throttling. Use plain EC2 or ECS when your function runs longer than 15 minutes or needs persistent state.
Lambda is not a general-purpose server. It is an event processor. Design your Go code to start fast, do one thing, and exit cleanly. Keep initialization outside the handler when possible. Cache database connections and HTTP clients in package-level variables. Reuse them across invocations. The container stays warm for a few minutes. Warm starts skip initialization entirely.