If you've been living in AWS for a while, you've probably seen a pattern:
- Python becomes the "default Lambda language" because it's quick to write and easy to ship.
- Go shows up when someone says, "This Lambda is basically a tiny compute worker… why is it slow and expensive?"
In this post, we'll build the same Lambda twice (Go and Python), deploy both with Terraform, put them behind Lambda Function URLs, and run a simple benchmark that makes Go look unfair, on purpose.
Use case: Telemetry burst aggregation
A client sends an array of events (endpoint, status code, duration). The function returns:
- total events
- unique users
- status code counts
- p95 duration (requires sorting a big slice/list)
- top endpoints by hit count
This is a very "Lambda-ish" job: bursty, CPU-heavy, and latency-sensitive.
Heads-up: Go's managed runtime is deprecated
AWS deprecated the go1.x managed runtime. Go is still supported on Lambda, but you run it through an OS-only runtime such as provided.al2023 (custom runtime) and ship a bootstrap executable in your zip.
Docs:
- Lambda runtimes: https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html
- Go on Lambda: https://docs.aws.amazon.com/lambda/latest/dg/lambda-golang.html
- Custom runtime entrypoint: https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html
This matters because it slightly changes packaging (but you still write normal Go with aws-lambda-go).
What you'll build
Project available at https://github.com/Femi-lawal/go_v_py_lambda
Two endpoints:
-
telemetry-go→provided.al2023→ compiledbootstrap -
telemetry-py→python3.12→app.handler
Both exposed through Function URLs so you can curl them without API Gateway.
⚠️ For a real system, do not use
AuthType = NONEin production. UseAWS_IAMor put CloudFront/WAF in front.
Function URL docs: https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html
Why Go outshines Python in this specific setup
Go advantages (for this use case)
- Faster cold starts (compiled binary, minimal runtime overhead)
- CPU-bound speed (JSON decode + sorting + maps)
- Lower memory pressure (often fewer "surprise" allocations)
- Easy to parallelize parsing/aggregation with goroutines when it makes sense
Python advantages (in general)
- Fast iteration and fewer steps to "get something running"
- Huge ecosystem (data parsing, scientific libs, AWS tooling)
- Great when your Lambda is mostly orchestration (glue code) or IO-bound work
The trade-off
Go is fantastic when your Lambda is:
- hot path
- CPU-heavy
- cost-sensitive at scale
Python is fantastic when your Lambda is:
- mostly IO (calling APIs, DynamoDB, S3, etc.)
- rapidly changing
- maintained by teams who live in Python day-to-day
Project layout
Here's a small repo layout you can copy:
.
├── infra
│ ├── main.tf
│ ├── versions.tf
│ └── outputs.tf
├── lambda-go
│ ├── go.mod
│ ├── go.sum # auto-generated by go mod tidy
│ └── main.go
├── lambda-py
│ └── app.py
└── scripts
├── gen_payload.py
└── bench.sh
The Lambda code (same logic, two languages)
1) Python: lambda-py/app.py
import json
import time
def handler(event, context):
start = time.time()
body = event.get("body") or ""
if event.get("isBase64Encoded"):
import base64
body = base64.b64decode(body)
if isinstance(body, (bytes, bytearray)):
body = body.decode("utf-8")
try:
events_ = json.loads(body) if body else []
if not isinstance(events_, list):
raise ValueError("Expected a JSON array")
except Exception as e:
return {
"statusCode": 400,
"headers": {"content-type": "application/json"},
"body": json.dumps({"error": "invalid request", "detail": str(e)}),
}
counts = {}
status_counts = {}
unique_users = set()
durations = []
for ev in events_:
if not isinstance(ev, dict):
continue
endpoint = ev.get("endpoint", "unknown")
counts[endpoint] = counts.get(endpoint, 0) + 1
status = str(ev.get("status", "unknown"))
status_counts[status] = status_counts.get(status, 0) + 1
uid = ev.get("user_id")
if uid is not None:
unique_users.add(str(uid))
d = ev.get("duration_ms", 0)
try:
durations.append(int(d))
except Exception:
durations.append(0)
durations.sort()
p95 = durations[int(0.95 * (len(durations) - 1))] if durations else 0
top_endpoints = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:5]
compute_ms = int((time.time() - start) * 1000)
return {
"statusCode": 200,
"headers": {"content-type": "application/json"},
"body": json.dumps(
{
"total_events": len(events_),
"unique_users": len(unique_users),
"p95_duration_ms": p95,
"top_endpoints": top_endpoints,
"status_counts": status_counts,
"compute_ms": compute_ms,
"language": "python",
}
),
}
2) Go: lambda-go/main.go
Go on
provided.al2023expects your deployment zip to contain an executable namedbootstrapat the root.
package main
import (
"context"
"encoding/json"
"sort"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
type TelemetryEvent struct {
Endpoint string `json:"endpoint"`
Status int `json:"status"`
DurationMS int `json:"duration_ms"`
UserID string `json:"user_id"`
}
type Response struct {
TotalEvents int `json:"total_events"`
UniqueUsers int `json:"unique_users"`
P95DurationMS int `json:"p95_duration_ms"`
TopEndpoints [][2]interface{} `json:"top_endpoints"`
StatusCounts map[string]int `json:"status_counts"`
ComputeMS int64 `json:"compute_ms"`
Language string `json:"language"`
Error string `json:"error,omitempty"`
ErrorDetail string `json:"detail,omitempty"`
}
func handler(ctx context.Context, req events.LambdaFunctionURLRequest) (events.LambdaFunctionURLResponse, error) {
start := time.Now()
var items []TelemetryEvent
if req.Body != "" {
if err := json.Unmarshal([]byte(req.Body), &items); err != nil {
out, _ := json.Marshal(Response{Error: "invalid request", ErrorDetail: err.Error(), Language: "go"})
return events.LambdaFunctionURLResponse{
StatusCode: 400,
Headers: map[string]string{"content-type": "application/json"},
Body: string(out),
}, nil
}
}
counts := make(map[string]int, 64)
statusCounts := make(map[string]int, 16)
unique := make(map[string]struct{}, 128)
durations := make([]int, 0, len(items))
for _, ev := range items {
ep := ev.Endpoint
if ep == "" {
ep = "unknown"
}
counts[ep]++
statusCounts[itoa(ev.Status)]++
if ev.UserID != "" {
unique[ev.UserID] = struct{}{}
}
durations = append(durations, ev.DurationMS)
}
sort.Ints(durations)
p95 := 0
if len(durations) > 0 {
p95 = durations[int(0.95*float64(len(durations)-1))]
}
// Top 5 endpoints
type kv struct {
k string
v int
}
tmp := make([]kv, 0, len(counts))
for k, v := range counts {
tmp = append(tmp, kv{k: k, v: v})
}
sort.Slice(tmp, func(i, j int) bool { return tmp[i].v > tmp[j].v })
topN := 5
if len(tmp) < topN {
topN = len(tmp)
}
top := make([][2]interface{}, 0, topN)
for i := 0; i < topN; i++ {
top = append(top, [2]interface{}{tmp[i].k, tmp[i].v})
}
out, _ := json.Marshal(Response{
TotalEvents: len(items),
UniqueUsers: len(unique),
P95DurationMS: p95,
TopEndpoints: top,
StatusCounts: statusCounts,
ComputeMS: time.Since(start).Milliseconds(),
Language: "go",
})
return events.LambdaFunctionURLResponse{
StatusCode: 200,
Headers: map[string]string{"content-type": "application/json"},
Body: string(out),
}, nil
}
// tiny int->string helper (avoids fmt.Sprintf)
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var buf [12]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
func main() {
lambda.Start(handler)
}
lambda-go/go.mod:
module telemetry-go
go 1.22
require github.com/aws/aws-lambda-go v1.48.0
Terraform: deploy both functions the same way
infra/versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
# Needs support for provided.al2023 and modern runtimes.
version = ">= 5.26.0"
}
}
}
provider "aws" {
region = var.aws_region
}
infra/main.tf
variable "aws_region" {
type = string
default = "us-east-1"
}
locals {
project = "go-vs-python-lambda"
}
data "aws_caller_identity" "current" {}
resource "aws_iam_role" "lambda_exec" {
name = "${local.project}-exec"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "basic" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# --------------------
# Packaging
# --------------------
# Python zip: just zip the folder
data "archive_file" "py_zip" {
type = "zip"
source_dir = "${path.module}/../lambda-py"
output_path = "${path.module}/dist/lambda-py.zip"
}
# Go zip: expects dist/bootstrap already built
data "archive_file" "go_zip" {
type = "zip"
source_file = "${path.module}/dist/bootstrap"
output_path = "${path.module}/dist/lambda-go.zip"
}
# --------------------
# Lambdas
# --------------------
resource "aws_lambda_function" "py" {
function_name = "${local.project}-py"
role = aws_iam_role.lambda_exec.arn
filename = data.archive_file.py_zip.output_path
source_code_hash = data.archive_file.py_zip.output_base64sha256
runtime = "python3.12"
handler = "app.handler"
timeout = 10
memory_size = 512
architectures = ["arm64"]
}
resource "aws_lambda_function" "go" {
function_name = "${local.project}-go"
role = aws_iam_role.lambda_exec.arn
filename = data.archive_file.go_zip.output_path
source_code_hash = data.archive_file.go_zip.output_base64sha256
runtime = "provided.al2023"
handler = "bootstrap"
timeout = 10
memory_size = 512
architectures = ["arm64"]
}
# --------------------
# Function URLs (public for demo)
# --------------------
resource "aws_lambda_function_url" "py" {
function_name = aws_lambda_function.py.function_name
authorization_type = "NONE"
}
resource "aws_lambda_function_url" "go" {
function_name = aws_lambda_function.go.function_name
authorization_type = "NONE"
}
# As of late 2024, Function URLs may require BOTH InvokeFunctionUrl and InvokeFunction permissions
# depending on your account settings. We grant both for compatibility.
resource "aws_lambda_permission" "py_url" {
statement_id = "AllowPublicInvokeUrlPy"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.py.function_name
principal = "*"
function_url_auth_type = "NONE"
}
resource "aws_lambda_permission" "py_invoke" {
statement_id = "AllowPublicInvokePy"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.py.function_name
principal = "*"
}
resource "aws_lambda_permission" "go_url" {
statement_id = "AllowPublicInvokeUrlGo"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.go.function_name
principal = "*"
function_url_auth_type = "NONE"
}
resource "aws_lambda_permission" "go_invoke" {
statement_id = "AllowPublicInvokeGo"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.go.function_name
principal = "*"
}
infra/outputs.tf
output "python_url" {
value = aws_lambda_function_url.py.function_url
}
output "go_url" {
value = aws_lambda_function_url.go.function_url
}
Build & deploy
1) Build the Go bootstrap for arm64
From the repo root:
mkdir -p infra/dist
cd lambda-go
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-s -w" -o ../infra/dist/bootstrap .
cd ..
chmod +x infra/dist/bootstrap
2) Terraform apply
cd infra
terraform init
terraform apply
Terraform will output both URLs.
Generate a payload and hit both endpoints
scripts/gen_payload.py
import json
import random
import string
def rand_user():
return "u_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
def main(n=20000):
endpoints = ["/login", "/search", "/checkout", "/profile", "/feed", "/ping"]
out = []
for _ in range(n):
out.append({
"endpoint": random.choice(endpoints),
"status": random.choice([200, 200, 200, 201, 400, 401, 403, 500]),
"duration_ms": int(random.expovariate(1/120)) + random.randint(0, 30),
"user_id": rand_user(),
})
print(json.dumps(out))
if __name__ == "__main__":
main()
Run it:
python3 scripts/gen_payload.py > payload.json
Invoke:
PY_URL="$(cd infra && terraform output -raw python_url)"
GO_URL="$(cd infra && terraform output -raw go_url)"
curl -s -X POST "$PY_URL" -H "content-type: application/json" --data-binary @payload.json | jq
curl -s -X POST "$GO_URL" -H "content-type: application/json" --data-binary @payload.json | jq
Quick benchmark (side-by-side)
Option A: hey
# Install hey (if you don't have it)
# macOS: brew install hey
# Linux: https://github.com/rakyll/hey
hey -n 200 -c 20 -m POST -H "content-type: application/json" -D payload.json "$PY_URL"
hey -n 200 -c 20 -m POST -H "content-type: application/json" -D payload.json "$GO_URL"
Option B: a tiny bench.sh
scripts/bench.sh:
#!/usr/bin/env bash
set -euo pipefail
PY_URL="$(cd infra && terraform output -raw python_url)"
GO_URL="$(cd infra && terraform output -raw go_url)"
echo "Python URL: $PY_URL"
echo "Go URL: $GO_URL"
for url in "$PY_URL" "$GO_URL"; do
echo
echo "== Benchmarking: $url"
hey -n 200 -c 20 -m POST -H "content-type: application/json" -D payload.json "$url"
done
What to look for in the results
When you benchmark:
- p95 / p99 latency: Go often wins when the handler is CPU-heavy.
- cold start: run once after no traffic for a while and compare first-hit latency.
- cost: Lambda charges for GB-seconds. If Go finishes faster at the same memory size, your bill can drop.
Also look at CloudWatch logs and metrics:
- Duration
- Max memory used
- Init duration (cold start)
Actual Benchmark Results
We ran a comprehensive benchmark with the following configuration:
- Memory: 512 MB (both functions)
- Architecture: ARM64 (Graviton2)
- Region: us-east-1
- Iterations per test: 30
- Payload sizes: 100, 1,000, 5,000, 10,000, and 20,000 events
Latency Summary (all times in milliseconds)
| Events | Payload Size | Language | Cold Start | Avg Latency | P50 | P95 | P99 |
|---|---|---|---|---|---|---|---|
| 100 | 8 KB | Python | 272 | 70 | 66 | 89 | 98 |
| 100 | 8 KB | Go | 136 | 41 | 39 | 54 | 55 |
| 1,000 | 82 KB | Python | 68 | 72 | 70 | 82 | 107 |
| 1,000 | 82 KB | Go | 46 | 53 | 52 | 68 | 74 |
| 5,000 | 411 KB | Python | 124 | 124 | 121 | 151 | 160 |
| 5,000 | 411 KB | Go | 135 | 108 | 102 | 130 | 140 |
| 10,000 | 822 KB | Python | 220 | 205 | 202 | 236 | 263 |
| 10,000 | 822 KB | Go | 190 | 197 | 199 | 221 | 261 |
| 20,000 | 1.6 MB | Python | 422 | 473 | 460 | 573 | 595 |
| 20,000 | 1.6 MB | Go | 357 | 359 | 359 | 391 | 396 |
Go vs Python Speedup Factor
| Payload | Avg Latency Speedup | P95 Latency Speedup | Cold Start Speedup |
|---|---|---|---|
| 100 events (8 KB) | 1.70x faster | 1.66x faster | 2.00x faster |
| 1,000 events (82 KB) | 1.35x faster | 1.21x faster | 1.47x faster |
| 5,000 events (411 KB) | 1.15x faster | 1.16x faster | 0.92x |
| 10,000 events (822 KB) | 1.04x faster | 1.07x faster | 1.16x faster |
| 20,000 events (1.6 MB) | 1.32x faster | 1.46x faster | 1.18x faster |
Key Observations
Cold Starts: Go's cold start is up to 2x faster for small payloads. At 100 events, Go cold starts at ~136ms vs Python's ~272ms. This matters for bursty, event-driven workloads.
Small Payloads Win Big: For small to medium payloads (100-1000 events), Go's advantage is most pronounced (1.35x-1.70x faster). This is where the JSON parsing and initial setup overhead dominates.
Large Payloads Converge: At 10,000+ events, the speedup narrows (1.04x-1.32x) because the actual computation time dominates over runtime overhead. However, Go maintains a consistent advantage in tail latencies (P95/P99).
-
Predictable Tail Latencies: Go's P95 to P99 spread is tighter. For 20,000 events:
- Python: P95=573ms, P99=595ms (22ms spread)
- Go: P95=391ms, P99=396ms (5ms spread)
This predictability is critical for SLO compliance.
-
Internal Compute Time: At larger payloads, Python's internal compute time (
compute_msfrom the response) is actually slightly lower than Go's for sorting operations (20K events: Python=117ms, Go=140ms). This is because Python'stimsortis highly optimized C code, while Go'ssort.Intsis pure Go. However, thecompute_msmetric only measures post-JSON-parse processing. Go's faster JSON deserialization (which dominates at scale) isn't captured in this number. The total end-to-end latency still favors Go due to faster JSON parsing and lower runtime overhead.
Cost Implications
With Lambda ARM64 (Graviton2) pricing at $0.0000133334 per GB-second (20% cheaper than x86):
| Scenario | Python (avg) | Go (avg) | Monthly Savings (1M invocations) |
|---|---|---|---|
| 100 events | 70ms | 41ms | $0.20 |
| 1,000 events | 72ms | 53ms | $0.13 |
| 20,000 events | 473ms | 359ms | $0.76 |
Calculation: (Python_ms - Go_ms) / 1000 × 0.5 GB × $0.0000133334 × 1,000,000 invocations
At scale (100M invocations/month with 20,000-event payloads), that's approximately $76/month savings just by choosing Go, plus the inherent 20% ARM64 discount on top of x86 pricing.
When I'd choose Go vs Python for Lambda
I reach for Go when:
- this function is on the hot path (lots of traffic)
- it does non-trivial compute (parsing, compression, crypto, transforms)
- I care about predictable latency at scale
- I want small deployment packages and simple dependencies
I reach for Python when:
- the function mostly orchestrates AWS services
- the logic changes often (product iteration)
- I need a library ecosystem advantage
- it's a glue script with real business value and low perf pressure
Final notes and improvements
If you want to make this "production-ish":
- flip Function URLs to
AWS_IAMauth (or front with CloudFront/WAF) - set log retention (so you don't pay forever)
- add structured logging + tracing
- add provisioned concurrency if you need consistent latency under cold starts
Additional Benchmark Scenarios to Try
1. Memory Configuration Comparison
Try running the same benchmarks at different memory levels:
# In main.tf, change memory_size to test:
memory_size = 256 # Minimum viable
memory_size = 512 # Balanced (our test)
memory_size = 1024 # 2x CPU
memory_size = 1769 # Full vCPU
memory_size = 3008 # 2 vCPUs
Expected behavior: Go benefits less from extra memory since it's already efficient. Python may see bigger gains from more CPU (Lambda CPU scales with memory).
2. Concurrent Request Simulation
Test how each handles concurrent bursts:
# Using hey for concurrency testing
hey -n 500 -c 50 -m POST -H "content-type: application/json" -D payload.json "$GO_URL"
hey -n 500 -c 50 -m POST -H "content-type: application/json" -D payload.json "$PY_URL"
Watch for:
- Error rates under load
- Latency distribution (P50 vs P99 gap)
- Throttling behavior
3. I/O-Bound Workload Comparison
Modify the Lambda to include DynamoDB or S3 calls:
# In app.py - add I/O operation
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('telemetry-events')
# Write aggregated result to DynamoDB
table.put_item(Item={'id': str(uuid.uuid4()), 'result': result})
Expected result: The Go vs Python gap narrows significantly when I/O dominates. For pure I/O workloads, Python's simplicity often wins.
4. Package Size Impact
Compare deployment package sizes:
| Language | Package Size | Contains |
|---|---|---|
| Go | 6.3 MB | Single static binary |
| Python | 1.2 KB | Just app.py
|
But with dependencies:
| Language | Package Size | Contains |
|---|---|---|
| Go | 6.3 MB | Still just the binary |
| Python + pandas + numpy | 50+ MB | Requires layers or container |
5. Provisioned Concurrency Test
To eliminate cold starts entirely:
resource "aws_lambda_provisioned_concurrency_config" "go" {
function_name = aws_lambda_function.go.function_name
provisioned_concurrent_executions = 10
qualifier = aws_lambda_function.go.version
}
This adds ~$0.000004463 per GB-second for provisioned capacity.
Real-World Use Cases Where These Results Matter
✅ Good fit for Go Lambda:
- API Gateway backends - Low latency matters for user-facing APIs
- Kinesis/SQS processors - High throughput event processing
- Real-time data aggregation - Like our telemetry example
- Image/video thumbnail generation - CPU-bound transforms
- JWT validation layers - Crypto operations benefit from Go
✅ Good fit for Python Lambda:
- ML inference with SageMaker - Rich SDK ecosystem
- Data pipeline orchestration - Step Functions triggers
- S3 event handlers - boto3 makes this trivial
- Slack/Discord bots - Rapid iteration matters more
- One-off automation scripts - Time-to-deployment wins
Cleanup
Don't forget to tear down your infrastructure when done:
cd infra
terraform destroy -auto-approve
Reproducibility
To reproduce these benchmarks yourself:
# 1. Clone and enter the project
git clone https://github.com/Femi-lawal/go_v_py_lambda
cd go_v_py_lambda
# 2. Build the Go binary for ARM64 Lambda
cd lambda-go
go mod tidy
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-s -w" -o ../infra/dist/bootstrap .
cd ..
# 3. Deploy with Terraform
cd infra
terraform init
terraform apply -auto-approve
# 4. Generate test payload
python3 scripts/gen_payload.py 20000 > payload.json
# 5. Run benchmarks (requires 'hey' - install via: go install github.com/rakyll/hey@latest)
PY_URL="$(terraform output -raw python_url)"
GO_URL="$(terraform output -raw go_url)"
hey -n 100 -c 10 -m POST -H "content-type: application/json" -D ../payload.json "$PY_URL"
hey -n 100 -c 10 -m POST -H "content-type: application/json" -D ../payload.json "$GO_URL"
# 6. Cleanup when done
terraform destroy -auto-approve
Summary
Our benchmarks confirm the conventional wisdom with hard numbers:
| Metric | Go Advantage |
|---|---|
| Cold Start (small payload) | 2x faster |
| Warm Latency (small payload) | 1.7x faster |
| Warm Latency (large payload) | 1.3x faster |
| P95 Tail Latency | 1.2x-1.7x tighter |
| Monthly Cost Savings (ARM64) | ~$76 at 100M requests |
The takeaway: If your Lambda is on the hot path, does CPU work, and you care about cost or latency SLOs, Go is worth the learning curve. If you're gluing AWS services together or iterating rapidly, Python's ergonomics win.
Happy building


Top comments (0)