# Build and Deploy a Go Lambda Function

- URL: https://karandeepsingh.ca/posts/build-deploy-go-lambda-function/
- Author: Karandeep Singh
- Published: 2026-02-14
- Series: Serverless on AWS
- Tags: golang, aws, lambda, serverless
- Summary: Build your first Go Lambda function from scratch. We start simple, hit real errors, fix them, and work our way up to a production-ready API endpoint.

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.

![Data center servers running cloud functions for serverless deployments](https://karandeepsingh.ca/images/posts/build-deploy-go-lambda.jpg)

## 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:**
1. Bare minimum handler that returns a string
2. Hit the "this isn't how Lambda works" wall
3. Add proper request/response structs
4. Handle errors like a grown-up
5. Wire it to API Gateway
6. Deploy and test it live

## Prerequisites

- Go 1.21+ installed (`go version`)
- AWS CLI configured (`aws sts get-caller-identity` should 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:

```bash
mkdir go-lambda-api && cd go-lambda-api
go mod init go-lambda-api
go get github.com/aws/aws-lambda-go
```

**`main.go`**
```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:

```bash
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:

```bash
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-role` and attach the `AWSLambdaBasicExecutionRole` policy.

Test it:

```text
$ aws lambda invoke --function-name go-hello output.txt && cat output.txt
"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.

The whole change is a Request struct and one handler parameter:

@@ -1,11 +1,17 @@
 package main
 
 import (
+	"fmt"
+
 	"github.com/aws/aws-lambda-go/lambda"
 )
 
-func handler() (string, error) {
-	return "hello from lambda", nil
+type Request struct {
+	Name string `json:"name"`
+}
+
+func handler(req Request) (string, error) {
+	return fmt.Sprintf("hello %s", req.Name), nil
 }
 
 func main() {
```

The full listing, for reference:

**`main.go`**
```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:

```bash
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:

```text
$ aws lambda invoke \
>   --function-name go-hello \
>   --payload '{"name": "Karandeep"}' \
>   output.txt && cat output.txt
"hello Karandeep"
```

Now test it *without* a name:

```text
$ aws lambda invoke \
>   --function-name go-hello \
>   --payload '{}' \
>   output.txt && cat output.txt
"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.

Two additions: a Response struct, and an error when the name is empty:

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/aws/aws-lambda-go/lambda"
@@ -10,8 +11,20 @@
 	Name string `json:"name"`
 }
 
-func handler(req Request) (string, error) {
-	return fmt.Sprintf("hello %s", req.Name), nil
+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() {
```

The full listing, for reference:

**`main.go`**
```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:

```bash
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:

```bash
aws lambda invoke --function-name go-hello \
  --payload '{"name": "Karandeep"}' output.txt && cat output.txt
```

**Expected output:**
```json
{"message":"hello Karandeep","status":200}
```

Test with missing name:

```bash
aws lambda invoke --function-name go-hello \
  --payload '{}' output.txt && cat output.txt
```

**Expected output:**
```json
{"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`**
```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:

```bash
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:

```bash
aws lambda invoke --function-name go-hello \
  --payload '{"body": "{\"name\": \"Karandeep\"}"}' \
  output.txt && cat output.txt
```

**Expected output:**
```json
{"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.

```bash
# 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:

```json
{"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:

```bash
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):

```bash
curl -X POST https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Karandeep"}'
```

**Expected output:**
```json
{"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:

```go
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:

```bash
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
```

```bash
curl -X POST https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Karandeep"}'
```

**Expected output:**
```json
{"message":"hello Karandeep","requestId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
```

Check CloudWatch to see your structured logs:

```bash
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:

1. **Input parsing:** deserialize JSON into Go structs
2. **Validation:** reject bad input with clear error messages
3. **API Gateway integration:** proper proxy request/response types
4. **CORS headers:** so browsers can call your API
5. **Context and logging:** request ID tracking for production debugging
6. **A reusable `respond` helper:** 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

```bash
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](https://karandeepsingh.ca/posts/go-aws-s3-cli-tool/) to see Go + AWS SDK patterns for working with S3, or [Terraform From Scratch](https://karandeepsingh.ca/posts/terraform-getting-started-aws/) to provision your Lambda infrastructure as code instead of clicking through the console.

## Cheat Sheet

Copy-paste reference for Go Lambda functions.

**Minimal handler:**
```go
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:**
```go
var body MyStruct
json.Unmarshal([]byte(req.Body), &body)
```

**Return JSON response with CORS:**
```go
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:**
```go
lc, _ := lambdacontext.FromContext(ctx)
requestID := lc.AwsRequestID
```

**Build and deploy:**
```bash
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 `bootstrap` for `provided.al2023` runtime
- Always build with `GOOS=linux GOARCH=amd64` because Lambda runs on Amazon Linux
- API Gateway sends body as a JSON string in `req.Body`, so you parse it yourself
- Return errors via `APIGatewayProxyResponse` with status codes, not via Go's `error` return
- Add `lambda:InvokeFunction` permission for API Gateway or it gets "Internal Server Error"
