Most tutorials hand you the final, polished Lambda function and say “deploy this.” That teaches you nothing about why the code looks that way. Instead, we’re going to build a Go Lambda function the way you’d actually build one: start naive, hit problems, fix them, and end up with something real.
By the end, you’ll have a Go function behind API Gateway that actually does something useful.
What We’re Building
A Lambda function in Go that processes requests and returns structured JSON responses. We’ll start with the absolute minimum, break things on purpose, and build up to a proper API endpoint.
The journey:
- Bare minimum handler that returns a string
- Hit the “this isn’t how Lambda works” wall
- Add proper request/response structs
- Handle errors like a grown-up
- Wire it to API Gateway
- Deploy and test it live
Prerequisites
- Go 1.21+ installed (
go version) - AWS CLI configured (
aws sts get-caller-identityshould work) - An AWS account with Lambda permissions
Step 1: The Naive Handler
What: Write the simplest possible Lambda function.
Why: We need to see what the minimum viable Lambda function looks like before we can understand what’s missing.
Create your project:
mkdir go-lambda-api && cd go-lambda-api
go mod init go-lambda-api
go get github.com/aws/aws-lambda-go
main.go
package main
import (
"github.com/aws/aws-lambda-go/lambda"
)
func handler() (string, error) {
return "hello from lambda", nil
}
func main() {
lambda.Start(handler)
}
That’s it. Five lines of actual code. The lambda.Start() function is the entry point. It registers your handler and starts listening for invocation events from the Lambda runtime.
Build and zip it:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
Why GOOS=linux? Lambda runs on Amazon Linux, not your Mac or Windows machine. Why bootstrap? That’s the filename Lambda looks for when using the provided.al2023 runtime.
Deploy it:
aws lambda create-function \
--function-name go-hello \
--runtime provided.al2023 \
--handler bootstrap \
--role arn:aws:iam::YOUR_ACCOUNT_ID:role/lambda-execution-role \
--zip-file fileb://function.zip
Note: You need a Lambda execution role. If you don’t have one, create it with
aws iam create-roleand attach theAWSLambdaBasicExecutionRolepolicy.
Test it:
aws lambda invoke --function-name go-hello output.txt && cat output.txt
Expected output:
"hello from lambda"
It works. But this function is useless. It ignores all input and returns a plain string. Let’s fix that.
Step 2: Accept Input (And Break Things)
What: Add input parameters to our handler.
Why: A Lambda function that ignores its input is just an expensive echo. We need to process incoming data.
Let’s try the obvious thing: add a parameter.
main.go
package main
import (
"fmt"
"github.com/aws/aws-lambda-go/lambda"
)
type Request struct {
Name string `json:"name"`
}
func handler(req Request) (string, error) {
return fmt.Sprintf("hello %s", req.Name), nil
}
func main() {
lambda.Start(handler)
}
We defined a Request struct with a Name field. The json:"name" tag tells Go’s JSON decoder to map the name field from the incoming JSON to our struct. Lambda automatically deserializes the event payload into whatever type your handler accepts.
Build, zip, and update:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
aws lambda update-function-code \
--function-name go-hello \
--zip-file fileb://function.zip
Test with a payload:
aws lambda invoke \
--function-name go-hello \
--payload '{"name": "Karandeep"}' \
output.txt && cat output.txt
Expected output:
"hello Karandeep"
Now test it without a name:
aws lambda invoke \
--function-name go-hello \
--payload '{}' \
output.txt && cat output.txt
Expected output:
"hello "
That’s ugly. An empty name gives us “hello " with a trailing space. We need to handle missing input. This is the kind of bug that ships to production because the happy path worked fine.
Step 3: Handle Missing Input
What: Add validation and return proper errors.
Why: The empty name problem above is exactly the kind of silent bug that causes confusing behavior in production. Lambda functions should fail loudly when input is bad.
main.go
package main
import (
"errors"
"fmt"
"github.com/aws/aws-lambda-go/lambda"
)
type Request struct {
Name string `json:"name"`
}
type Response struct {
Message string `json:"message"`
Status int `json:"status"`
}
func handler(req Request) (Response, error) {
if req.Name == "" {
return Response{}, errors.New("name is required")
}
return Response{
Message: fmt.Sprintf("hello %s", req.Name),
Status: 200,
}, nil
}
func main() {
lambda.Start(handler)
}
Two changes here. First, we added a Response struct so we return structured JSON instead of a raw string. Second, when Name is empty, we return an error. Lambda will catch this and return a 500-level error with our message.
Build, zip, update, and test:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
aws lambda update-function-code --function-name go-hello --zip-file fileb://function.zip
Test with valid input:
aws lambda invoke --function-name go-hello \
--payload '{"name": "Karandeep"}' output.txt && cat output.txt
Expected output:
{"message":"hello Karandeep","status":200}
Test with missing name:
aws lambda invoke --function-name go-hello \
--payload '{}' output.txt && cat output.txt
Expected output:
{"errorMessage":"name is required","errorType":"errorString"}
Now we get a clear error instead of silent garbage. But there’s a problem: when this function sits behind API Gateway, that error format won’t produce a proper HTTP response. Let’s fix that next.
Step 4: Make It API Gateway-Ready
What: Switch to the API Gateway proxy request/response format.
Why: API Gateway doesn’t send raw JSON to your Lambda. It wraps everything in an APIGatewayProxyRequest with headers, query strings, path parameters, and a body. If you don’t use the right types, your function will silently receive empty structs.
This is where most Go Lambda tutorials should start, but they skip the context of why you need these specific types.
main.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
type RequestBody struct {
Name string `json:"name"`
}
func handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Parse the body
var body RequestBody
if err := json.Unmarshal([]byte(req.Body), &body); err != nil {
return respond(http.StatusBadRequest, map[string]string{
"error": "invalid JSON in request body",
})
}
// Validate
if body.Name == "" {
return respond(http.StatusBadRequest, map[string]string{
"error": "name is required",
})
}
// Success
return respond(http.StatusOK, map[string]string{
"message": fmt.Sprintf("hello %s", body.Name),
})
}
// respond builds an API Gateway proxy response with JSON body and CORS headers.
func respond(status int, body interface{}) (events.APIGatewayProxyResponse, error) {
jsonBody, _ := json.Marshal(body)
return events.APIGatewayProxyResponse{
StatusCode: status,
Headers: map[string]string{
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
Body: string(jsonBody),
}, nil
}
func main() {
lambda.Start(handler)
}
The big change: our handler now accepts events.APIGatewayProxyRequest and returns events.APIGatewayProxyResponse. The request body comes as a raw JSON string in req.Body, so we parse it ourselves with json.Unmarshal.
The respond helper builds a proper HTTP response with status code, JSON body, and CORS headers. Every Lambda behind API Gateway needs this pattern, and you’ll reuse it in every project.
Build and deploy:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
aws lambda update-function-code --function-name go-hello --zip-file fileb://function.zip
Test locally with an API Gateway-formatted event:
aws lambda invoke --function-name go-hello \
--payload '{"body": "{\"name\": \"Karandeep\"}"}' \
output.txt && cat output.txt
Expected output:
{"statusCode":200,"headers":{"Access-Control-Allow-Origin":"*","Content-Type":"application/json"},"body":"{\"message\":\"hello Karandeep\"}"}
Notice the difference: the response now has statusCode, headers, and body fields. That’s exactly what API Gateway needs to construct a proper HTTP response.
Step 5: Wire Up API Gateway
What: Create an API Gateway endpoint that triggers our Lambda.
Why: A Lambda function without a trigger is like a web server with no port open. API Gateway gives us a public HTTPS URL.
# Create the API
aws apigatewayv2 create-api \
--name go-hello-api \
--protocol-type HTTP \
--target arn:aws:lambda:us-east-1:YOUR_ACCOUNT_ID:function:go-hello
This creates an HTTP API (v2) with a default route pointing to your Lambda. It’s the fastest way to get an endpoint, just one command.
But it won’t work yet. Try hitting the URL you got back and you’ll get:
{"message":"Internal Server Error"}
Why? API Gateway doesn’t have permission to invoke your Lambda. This is the most common “it works in the console but not via API” problem:
aws lambda add-permission \
--function-name go-hello \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com
Now test it with curl (use the API endpoint from the create-api output):
curl -X POST https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ \
-H "Content-Type: application/json" \
-d '{"name": "Karandeep"}'
Expected output:
{"message":"hello Karandeep"}
You now have a live, public HTTPS endpoint running your Go code on Lambda.
Step 6: Add Context and Logging
What: Use the Lambda context for request tracking and add structured logging.
Why: When something breaks in production, the first thing you need is the request ID. Lambda provides it via context, but you have to actually use it.
main.go, updated handler signature:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-lambda-go/lambdacontext"
)
type RequestBody struct {
Name string `json:"name"`
}
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Extract request ID for logging
lc, _ := lambdacontext.FromContext(ctx)
requestID := lc.AwsRequestID
log.Printf("[%s] received request from %s", requestID, req.RequestContext.Identity.SourceIP)
// Parse the body
var body RequestBody
if err := json.Unmarshal([]byte(req.Body), &body); err != nil {
log.Printf("[%s] invalid JSON: %v", requestID, err)
return respond(http.StatusBadRequest, map[string]string{
"error": "invalid JSON in request body",
"requestId": requestID,
})
}
// Validate
if body.Name == "" {
log.Printf("[%s] missing name field", requestID)
return respond(http.StatusBadRequest, map[string]string{
"error": "name is required",
"requestId": requestID,
})
}
log.Printf("[%s] success: greeting %s", requestID, body.Name)
return respond(http.StatusOK, map[string]string{
"message": fmt.Sprintf("hello %s", body.Name),
"requestId": requestID,
})
}
func respond(status int, body interface{}) (events.APIGatewayProxyResponse, error) {
jsonBody, _ := json.Marshal(body)
return events.APIGatewayProxyResponse{
StatusCode: status,
Headers: map[string]string{
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
Body: string(jsonBody),
}, nil
}
func main() {
lambda.Start(handler)
}
Two additions. The handler now takes context.Context as its first parameter. Lambda populates this with the request ID, function ARN, and deadline. We extract the request ID and include it in every log line and every response. When a user reports a problem, they give you the requestId and you search CloudWatch for it. That’s production debugging.
Build, deploy, and test:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
aws lambda update-function-code --function-name go-hello --zip-file fileb://function.zip
curl -X POST https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ \
-H "Content-Type: application/json" \
-d '{"name": "Karandeep"}'
Expected output:
{"message":"hello Karandeep","requestId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
Check CloudWatch to see your structured logs:
aws logs tail /aws/lambda/go-hello --since 5m
What We Built
Starting from a 5-line handler that returned a string, we incrementally built:
- Input parsing: deserialize JSON into Go structs
- Validation: reject bad input with clear error messages
- API Gateway integration: proper proxy request/response types
- CORS headers: so browsers can call your API
- Context and logging: request ID tracking for production debugging
- A reusable
respondhelper: pattern you’ll use in every Lambda
Each step solved a real problem we actually hit. That’s the difference between copying a tutorial and understanding what you’re building.
Cleanup
aws lambda delete-function --function-name go-hello
aws apigatewayv2 delete-api --api-id YOUR_API_ID
Next Steps
This handler does one thing: greet someone. In a real project, you’d add:
- DynamoDB for persistence (store and retrieve data)
- Multiple routes with path-based routing in API Gateway
- Environment variables for configuration (table names, stage)
- A Makefile to automate the build-zip-deploy cycle
Check out Building a Go S3 CLI Tool to see Go + AWS SDK patterns for working with S3, or Terraform From Scratch to provision your Lambda infrastructure as code instead of clicking through the console.
Cheat Sheet
Copy-paste reference for Go Lambda functions.
Minimal handler:
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
return events.APIGatewayProxyResponse{StatusCode: 200, Body: `{"ok":true}`}, nil
}
func main() { lambda.Start(handler) }
Parse JSON body:
var body MyStruct
json.Unmarshal([]byte(req.Body), &body)
Return JSON response with CORS:
func respond(status int, body any) (events.APIGatewayProxyResponse, error) {
b, _ := json.Marshal(body)
return events.APIGatewayProxyResponse{
StatusCode: status,
Headers: map[string]string{"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"},
Body: string(b),
}, nil
}
Get request ID:
lc, _ := lambdacontext.FromContext(ctx)
requestID := lc.AwsRequestID
Build and deploy:
GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip function.zip bootstrap
aws lambda update-function-code --function-name NAME --zip-file fileb://function.zip
Key rules to remember:
- Handler signature:
func(context.Context, InputType) (OutputType, error) - Binary must be named
bootstrapforprovided.al2023runtime - Always build with
GOOS=linux GOARCH=amd64because Lambda runs on Amazon Linux - API Gateway sends body as a JSON string in
req.Body, so you parse it yourself - Return errors via
APIGatewayProxyResponsewith status codes, not via Go’serrorreturn - Add
lambda:InvokeFunctionpermission for API Gateway or it gets “Internal Server Error”