A minimal, modern AWS Lambda runtime for Go that focuses on performance, simplicity, and type safety.
Voker is a simplified alternative to aws-lambda-go that maintains full compatibility with the AWS Lambda Runtime API. It uses Go generics to provide compile-time type safety with a clean, single-function-signature design. It supports structured logging with slog and proper log levels for errors.
Optional subpackages (each imported only when you use it):
vokerhttp— serve Lambda HTTP events (Function URLs, API Gateway, ALB) with a standardhttp.Handler, including response streamingvokercfn— type-safe CloudFormation custom resourcesvokerslog— aslog.Handlertuned for AWS Lambda advanced logging controls
go get github.com/hotsock/vokerVoker requires Go 1.27 or later and processes handler input and output with
the encoding/json/v2 standard library
package. Compared to the classic encoding/json semantics, this means:
- Unmarshaling matches JSON object names to struct fields case-sensitively.
- Payloads containing duplicate object names or invalid UTF-8 are rejected
with a
Runtime.UnmarshalErrorinstead of being silently accepted. - Nil slices marshal as
[]and nil maps as{}(instead ofnull). Types that implement their own marshaling (json.MarshalerTo, or the classicjson.Marshaler) are unaffected and keep full control of their output, including emittingnull.
package main
import (
"context"
"github.com/hotsock/voker"
)
type MyEvent struct {
Name string `json:"name"`
}
type MyResponse struct {
Message string `json:"message"`
}
func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
return MyResponse{
Message: "Hello, " + event.Name,
}, nil
}
func main() {
voker.Start(handler)
}func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
lc, ok := voker.FromContext(ctx)
if ok {
log.Printf("Request ID: %s", lc.AwsRequestID)
log.Printf("Function ARN: %s", lc.InvokedFunctionArn)
log.Printf("X-Ray trace ID: %s", lc.TraceID)
}
deadline, _ := ctx.Deadline()
log.Printf("Function deadline: %s", deadline)
return MyResponse{Message: "success"}, nil
}func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
if event.Name == "" {
return MyResponse{}, fmt.Errorf("name is required")
}
return MyResponse{Message: "Hello, " + event.Name}, nil
}Return a *voker.ErrorResponse when the function error needs a stable,
application-specific error type. Voker preserves its errorType,
errorMessage, and optional stack trace in the Runtime API error payload:
return MyResponse{}, &voker.ErrorResponse{
Type: "Application.ValidationError",
Message: "name is required",
}Any JSON-deserializable type works as the event. For HTTP event sources
(Function URLs, API Gateway, ALB), use the vokerhttp
adapters instead of decoding the event yourself.
// Handle SQS events
type SQSEvent struct {
Records []SQSRecord `json:"Records"`
}
type SQSRecord struct {
Body string `json:"body"`
}
func handler(ctx context.Context, event SQSEvent) (string, error) {
for _, record := range event.Records {
log.Printf("Processing: %s", record.Body)
}
return "ok", nil
}Voker supports only one handler signature:
func(context.Context, TIn) (TOut, error)Where:
context.Contextis required (provides deadline, cancellation, Lambda metadata)TInis your input type (must be JSON-deserializable)TOutis your output type (JSON-serializable, or anio.Readerfor streaming)erroris required for error handling
Voker automatically supports Lambda Managed Instances. At startup it reads
AWS_LAMBDA_MAX_CONCURRENCY and starts that many Runtime API workers; when the
variable is missing or invalid it preserves standard Lambda's serial behavior.
voker.MaxConcurrency() returns the effective worker count.
Managed Instances can call the same handler concurrently within one process.
Handlers must protect mutable globals and shared caches, coordinate access to
shared /tmp paths, and use clients that permit concurrent calls. Each handler
receives an independent context, deadline, request ID, and LambdaContext.TraceID.
Handlers use LambdaContext.TraceID in both standard Lambda and Managed
Instances, keeping trace propagation invocation-scoped.
Lambda does not forcibly stop timed-out Managed Instances handlers. Watch
ctx.Done() and leave enough deadline margin to stop the next unit of work
before performing further side effects. Managed Instances also require JSON
logging; Voker's default logger honors Lambda's AWS_LAMBDA_LOG_FORMAT=JSON
setting. A logger supplied through WithLogger must likewise emit JSON.
Internal extensions are rejected during Managed Instances initialization because the Extensions API does not support invocation events in that compute mode. Use the Telemetry API's platform events when invocation reporting is needed.
See examples/managed-instances for a
self-contained SAM stack and live concurrency probe.
Declare TIn as jsontext.Value (or its alias json.RawMessage) to receive
the invocation payload verbatim. Voker skips unmarshaling — and JSON
validation — and hands the raw bytes straight to your handler, which is then
responsible for decoding them:
import (
"encoding/json/jsontext"
json "encoding/json/v2"
)
func handler(ctx context.Context, payload jsontext.Value) (Response, error) {
// payload is the raw request bytes, aliased (not copied) from the
// invocation buffer. Decode it yourself however you like.
var event MyEvent
if err := json.Unmarshal(payload, &event); err != nil {
return Response{}, err
}
// ...
}This is useful for handlers that work with large payloads and want to measure or control their own decoding rather than paying for an unmarshal up front. Because validation is skipped, the handler also sees empty or malformed payloads as-is instead of voker rejecting them.
The vokerhttp subpackage serves Lambda HTTP events with a standard
http.Handler. An adapter converts the event source's payload into an
*http.Request, runs your handler, and converts the response back —
including automatic base64 encoding for binary or compressed
(Content-Encoding) bodies and Content-Type sniffing to match net/http
server behavior.
import (
"net/http"
"github.com/hotsock/voker/vokerhttp"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Lambda!"))
})
vokerhttp.Start(mux, &vokerhttp.FunctionURL{})
}Built-in adapters:
| Adapter | Event source |
|---|---|
vokerhttp.FunctionURL{} |
Lambda Function URL (payload format 2.0) |
vokerhttp.APIGatewayV2{} |
API Gateway v2 HTTP API (payload format 2.0) |
vokerhttp.APIGatewayV1{} |
API Gateway v1 REST API Lambda proxy |
vokerhttp.ALB{} |
Application Load Balancer Lambda target group |
For ALB target groups with the lambda.multi_value_headers.enabled attribute,
set &vokerhttp.ALB{MultiValueHeaders: true}. Without multi-value headers,
ALB responses cannot carry repeated headers, so only the last Set-Cookie
survives.
The original Lambda event remains available from the request context:
func handler(w http.ResponseWriter, r *http.Request) {
event, ok := vokerhttp.EventFromContext[vokerhttp.FunctionURLRequest](r.Context())
// event.RequestContext.HTTP.SourceIP, event.RequestContext.Authorizer, ...
}Custom event sources can implement the vokerhttp.Adapter interface.
Register an internal extension
with voker.WithInternalExtension to run code in the handler process on
Lambda lifecycle events:
voker.Start(handler, voker.WithInternalExtension(voker.InternalExtension{
Name: "my-extension",
OnInit: func() error {
// Runs during initialization. An error or panic fails init.
return nil
},
OnInvoke: func(ctx context.Context, event voker.ExtensionEventPayload) {
// Runs for each INVOKE event; ctx carries the event deadline.
},
OnSIGTERM: func(ctx context.Context) {
// Lambda sends SIGTERM ~600ms before SIGKILL when extensions are
// registered; ctx has a 500ms deadline for cleanup.
},
}))INVOKE is the only Extensions API event available to internal extensions;
Lambda delivers SHUTDOWN only to external extensions, so voker exposes
shutdown via OnSIGTERM instead. Internal extensions are not supported on
Lambda Managed Instances. See examples/extension for a
complete example.
Return an io.Reader to stream bytes through the Lambda Runtime API instead of
JSON-encoding the response. If the returned value also implements
ContentType() string, voker propagates that content type; otherwise it uses
application/octet-stream. If it also implements io.Closer, voker closes it
after the Runtime API finishes consuming the response, including when the
stream fails.
func handler(ctx context.Context, event MyEvent) (io.Reader, error) {
reader, writer := io.Pipe()
go func() {
defer writer.Close()
_, _ = io.WriteString(writer, "first\n")
time.Sleep(time.Second)
_, _ = io.WriteString(writer, "second\n")
}()
return reader, nil
}For net/http handlers, use vokerhttp.StartStreaming. Its response
writer implements http.Flusher and preserves HTTP status, headers, repeated
headers, and cookies in Lambda's streaming metadata prelude.
vokerhttp.StartStreaming(mux, &vokerhttp.FunctionURL{})
vokerhttp.StartStreaming(mux, &vokerhttp.APIGatewayV1{})| Ingress | Buffered | Streaming |
|---|---|---|
| Lambda Function URL | Yes | Yes (RESPONSE_STREAM) |
| API Gateway v1 REST API | Yes | Yes (ResponseTransferMode: STREAM) |
| API Gateway v2 HTTP API | Yes | No |
| Application Load Balancer | Yes | No |
Streaming REST integrations must also use API Gateway's
response-streaming-invocations integration URI. See the complete deployable
matrix in examples/aws-ingress-probe.
For live Runtime API regression coverage—including buffered/streaming mode
selection, stream errors and cleanup, custom error payloads, and initialization
failure reporting—see examples/runtime-probe.
Use vokercfn.Start to run a type-safe CloudFormation custom resource. It
handles the presigned response URL protocol, reports handler errors as
CloudFormation failures, and supplies safe physical resource ID fallbacks.
package main
import (
"context"
"fmt"
"github.com/hotsock/voker/vokercfn"
)
type Properties struct {
Name string `json:"Name"`
}
type Data struct {
ARN string `json:"Arn"`
}
func handler(ctx context.Context, event vokercfn.Event[Properties]) (vokercfn.Result[Data], error) {
switch event.RequestType {
case vokercfn.RequestCreate:
return vokercfn.Result[Data]{
PhysicalResourceID: "thing-123",
Data: Data{ARN: "arn:example:thing-123"},
}, nil
case vokercfn.RequestUpdate, vokercfn.RequestDelete:
return vokercfn.Result[Data]{
PhysicalResourceID: event.PhysicalResourceID,
}, nil
default:
return vokercfn.Result[Data]{}, fmt.Errorf("unknown request type %q", event.RequestType)
}
}
func main() {
vokercfn.Start(handler)
}Result.Data values are available through Fn::GetAtt. Set Result.NoEcho
to mask them in CloudFormation responses. Returning a different physical
resource ID from an update tells CloudFormation the resource was replaced.
Responses that cannot be encoded or exceed CloudFormation's 4096-byte limit
are converted to compact FAILED responses so stack operations do not wait
for a timeout. See examples/cloudformation for a
deployable example validated against Create, Update, and Delete events in AWS.
The LambdaContext type contains metadata about the invocation:
type LambdaContext struct {
AwsRequestID string // Unique request ID
InvokedFunctionArn string // ARN of the invoked function
TraceID string // Invocation-scoped X-Ray trace header
TenantID string // Tenant ID (tenant isolation mode)
Identity CognitoIdentity // Cognito identity (if present)
ClientContext ClientContext // Client context (if present)
}Access it using voker.FromContext(ctx). TenantID carries the value of the
Lambda-Runtime-Aws-Tenant-Id header for functions using Lambda tenant
isolation mode
and is empty otherwise.
Voker logs with the standard library's log/slog. By default it creates a logger
from AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL using slog's built-in JSON
or text handlers. Provide your own with voker.WithLogger.
For ideal Lambda logging behavior, the optional vokerslog subpackage offers a
slog.Handler tuned for AWS Lambda advanced logging controls.
It is opt-in (import it only when you want it) and adds no extra dependency — the
request ID is read from voker.FromContext rather than aws-lambda-go.
import (
"log/slog"
"os"
"github.com/hotsock/voker"
"github.com/hotsock/voker/vokerslog"
)
func main() {
logger := slog.New(vokerslog.NewHandler(os.Stdout))
slog.SetDefault(logger)
voker.Start(handler, voker.WithLogger(logger))
}NewHandler auto-configures format (JSON or text) and level from
AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL, and enriches every record with
Lambda metadata (function name, version, and the request ID from the invocation
context). Options override the environment values:
| Option | Description |
|---|---|
WithJSON() |
Output in JSON format |
WithText() |
Output in text format |
WithLevel(slog.Leveler) |
Set the minimum log level |
WithSource() |
Include source file, function, and line number |
WithType(string) |
Set the type field (default: "app.log") |
WithoutTime() |
Omit the timestamp |
In addition to the standard slog levels, the handler maps Lambda's TRACE
(slog.LevelDebug - 4) and FATAL (slog.LevelError + 4) levels. A JSON record
looks like:
{
"level": "INFO",
"msg": "Lambda Invoked",
"record": {
"functionName": "my-func",
"version": "$LATEST",
"requestId": "abc-123"
},
"type": "app.log"
}The type + record envelope mirrors the shape of AWS Lambda Telemetry API
events,
so type works best as a low-cardinality category for filtering and routing
logs (e.g. filter type = "app.request" in CloudWatch Logs Insights) rather
than for per-request data. AWS reserves function, extension, and
platform.*; a dotted app.<category> namespace avoids collisions and matches
AWS's style — for example app.log (the default), app.request, or
app.audit.
The default comes from WithType (or "app.log"). A single record can override
it with a top-level string attribute keyed vokerslog.TypeKey ("type"); the
attribute sets the record's type instead of being emitted normally. Set it via
With to tag every record from a logger, or per call:
// All records from this logger are tagged "app.request".
requests := slog.New(handler).With(vokerslog.TypeKey, "app.request")
// Or override a single record (this wins over any With value):
slog.InfoContext(ctx, "audit event", vokerslog.TypeKey, "app.audit")Setting the type to "" omits the field for that record. An attribute keyed
type inside a group is left untouched and emitted normally.
Voker automatically handles errors and panics:
func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
return MyResponse{}, errors.New("something went wrong")
}
// Returns: {"errorMessage":"something went wrong","errorType":"HandlerError"}The reported errorType is the Go type name of the returned error, so a
custom error type surfaces under its own name:
type PaymentDeclinedError struct{ /* ... */ }
func (e *PaymentDeclinedError) Error() string { return "payment declined" }
// Returns: {"errorMessage":"payment declined","errorType":"PaymentDeclinedError"}Errors with no meaningful type name — errors.New, fmt.Errorf,
errors.Join, and anonymous types — report the stable name HandlerError.
A *voker.ErrorResponse anywhere in the error chain (including wrapped with
fmt.Errorf("...: %w", err)) is preserved verbatim, so use it when you need
full control of the reported type. Voker also reports the error type in the
Lambda-Runtime-Function-Error-Type header on Runtime API error posts.
func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
a := []string{"hey"}
fmt.Println(a[1]) // panic
// ...
}The panic is reported as the invocation's error response with the panic type, message, and a stack trace, and then the process exits (matching official AWS runtime behavior, so the execution environment is not reused after a panic):
{
"errorType": "Runtime.Panic.boundsError",
"errorMessage": "runtime error: index out of range [1] with length 1",
"stackTrace": [
{
"path": "/usr/local/go/src/runtime/panic.go",
"line": 859,
"label": "gopanic"
},
{
"path": "/usr/local/go/src/runtime/panic.go",
"line": 236,
"label": "panicBounds64"
},
{
"path": "/usr/local/go/src/runtime/asm_arm64.s",
"line": 1357,
"label": "panicBounds"
},
{
"path": "Code/voker/examples/error/main.go",
"line": 27,
"label": "handler"
},
{
"path": "Code/voker/voker.go",
"line": 347,
"label": "callHandler[...]"
},
{
"path": "Code/voker/voker.go",
"line": 286,
"label": "handleInvocationContext[...]"
},
{
"path": "Code/voker/voker.go",
"line": 113,
"label": "Start[...].func1"
},
{
"path": "Code/voker/voker.go",
"line": 222,
"label": "runInvocationWorkers.func1"
},
{
"path": "/usr/local/go/src/sync/waitgroup.go",
"line": 258,
"label": "(*WaitGroup).Go.func1"
},
{
"path": "/usr/local/go/src/runtime/asm_arm64.s",
"line": 1039,
"label": "goexit"
}
]
}func TestHandler(t *testing.T) {
event := MyEvent{Name: "World"}
response, err := handler(context.Background(), event)
assert.NoError(t, err)
assert.Equal(t, "Hello, World", response.Message)
}No mocking required - your handler is just a function!
GOOS=linux GOARCH=arm64 go build -o bootstrap main.go
zip function.zip bootstrapAWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Resources:
MyFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: .
Handler: bootstrap
Runtime: provided.al2023
Architectures: [arm64]lambda.NewFunction(stack, jsii.String("MyFunction"), &lambda.FunctionProps{
Runtime: lambda.Runtime_PROVIDED_AL2023(),
Handler: jsii.String("bootstrap"),
Code: lambda.Code_FromAsset(jsii.String("./function.zip"), nil),
Architecture: lambda.Architecture_ARM_64(),
})import "github.com/aws/aws-lambda-go/lambda"
func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
// ...
}
func main() {
lambda.StartHandlerFunc(handler)
}import "github.com/hotsock/voker"
func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
// ...
}
func main() {
voker.Start(handler)
}That's it! If you were using the standard func(context.Context, TIn) (TOut, error) signature, it's a drop-in replacement.
If you were using lambdacontext.LambdaContext (most likely lambdacontext.FromContext(ctx) in your code), switch those calls to voker.FromContext(ctx).
If you were using aws-lambda-go/events types for HTTP event sources or net/http adapters like aws-lambda-go-api-proxy, see vokerhttp.
See LICENSE.