ekofyi
Lambda on Lambda: Running Haskell in AWS Lambda Without Losing Your Mind
Automation Patterns10 min read

Lambda on Lambda: Running Haskell in AWS Lambda Without Losing Your Mind

A practical deep-dive into running Haskell on AWS Lambda — why it works better than you'd expect, the cold start reality, and how to actually ship functional code to serverless infrastructure.

Why Haskell on Lambda Is Less Crazy Than It Sounds

Imagine you're building a data processing pipeline. You want strong type guarantees, you want correctness, and you want it to scale without managing servers. Your team knows Haskell. The obvious answer is AWS Lambda — except Lambda doesn't officially support Haskell. So what do you do?

You run Lambda on Lambda. Haskell's lambda calculus roots meeting AWS's serverless compute. It's a satisfying bit of wordplay, but more importantly, it's a genuinely viable approach that's been quietly maturing while most of the serverless world obsesses over TypeScript and Python.

Jack Kelly published a detailed walkthrough on this exact setup, and it's worth paying attention to. Not because Haskell on Lambda is new — people have been doing it since custom runtimes landed — but because the tooling and approach have gotten significantly more practical. The friction that used to make this a weekend hack project has dropped considerably.

Here's the thing: if you care about correctness in your serverless functions, if you're tired of runtime type errors in production Lambda invocations, this is worth your time even if you never ship Haskell to prod. The patterns here apply to any compiled language on Lambda's custom runtime.

The Custom Runtime Approach

AWS Lambda supports "custom runtimes" through the provided.al2023 runtime. Instead of Lambda managing your language runtime, you ship a bootstrap executable that implements Lambda's Runtime API. Your function becomes a compiled binary that Lambda just... runs.

This is how Haskell (and Rust, and Go before it got official support, and any other compiled language) works on Lambda. You compile your Haskell code to a static binary, name it bootstrap, zip it up, and deploy. Lambda doesn't care what language produced the binary — it just needs something that speaks the Runtime API protocol.

The Runtime API itself is straightforward HTTP. Your bootstrap process starts up, polls an endpoint for invocation events, processes them, and posts the response back. It's a loop. Here's what that looks like conceptually:

haskell
module Main where

import AWS.Lambda.Runtime (runLambda)
import AWS.Lambda.Events.APIGateway (APIGatewayRequest, APIGatewayResponse)
import qualified Data.Aeson as Aeson

handler :: APIGatewayRequest -> IO APIGatewayResponse
handler request = do
  -- Your actual logic here
  pure $ APIGatewayResponse 200 headers body

main :: IO ()
main = runLambda handler

That runLambda function handles all the Runtime API ceremony — the polling loop, error reporting, context propagation. Your code just implements the handler. Clean separation.

The key insight is that Haskell's type system gives you something most Lambda languages don't: compile-time guarantees about your event handling. If your handler type says it takes an APIGatewayRequest, the compiler ensures you're actually handling that event shape correctly. No more "undefined is not a function" at 3 AM in production.

Technical Deep-Dive: Building and Deploying

The build process is where most people bounce off Haskell-on-Lambda. You need a Linux x86_64 (or arm64) binary, statically linked or with the right dynamic libraries available. If you're developing on macOS, that means cross-compilation or Docker builds.

The modern approach uses Cabal or Stack with a Docker-based build. Here's a minimal Dockerfile for producing a Lambda-ready binary:

dockerfile
FROM public.ecr.aws/lambda/provided:al2023 AS runtime

FROM haskell:9.6 AS builder

WORKDIR /app
COPY . .

RUN cabal update && \
    cabal build --enable-executable-static && \
    cp $(cabal list-bin my-lambda-function) /app/bootstrap

FROM runtime
COPY --from=builder /app/bootstrap ${LAMBDA_RUNTIME_DIR}/bootstrap
RUN chmod +x ${LAMBDA_RUNTIME_DIR}/bootstrap

CMD ["handler"]

The --enable-executable-static flag is doing the heavy lifting here. It tells GHC to produce a statically-linked binary, which means you don't need to worry about whether Lambda's Amazon Linux environment has the right shared libraries. The binary is self-contained.

But static linking with GHC has historically been painful on some platforms. If you hit issues, the alternative is building on Amazon Linux directly (or an equivalent Docker image) and dynamically linking against the libraries you know will be present in the Lambda environment.

For deployment, you have two paths. The container image approach (shown above) lets you push to ECR and point Lambda at the image. The zip approach is lighter:

bash
# Build the binary
cabal build --enable-executable-static

# Find and package it
cp $(cabal list-bin my-lambda-function) bootstrap
chmod +x bootstrap
zip function.zip bootstrap

# Deploy
aws lambda update-function-code \
  --function-name my-haskell-function \
  --zip-file fileb://function.zip

This gives you a deployment artifact that's typically 15-30 MB for a non-trivial Haskell application. That's larger than a Python zip but smaller than most Java deployments, and the cold start characteristics are dramatically better than JVM-based runtimes.

Speaking of cold starts — this is where Haskell actually shines compared to what you'd expect. A statically-compiled Haskell binary starts in the low hundreds of milliseconds. Not as fast as Go or Rust (which can cold-start in under 50ms), but significantly faster than Java or .NET on Lambda. The GHC runtime system initializes quickly when there's no dynamic linking or classloading happening.

One gotcha: GHC's garbage collector is designed for throughput, not latency. If your Lambda function allocates heavily, you might see occasional GC pauses. For most request/response workloads this is fine — we're talking single-digit millisecond pauses — but if you're doing heavy data processing in a Lambda with tight timeout constraints, profile your allocation patterns.

The type-safety payoff shows up most clearly in event parsing. Consider how a typical Node.js Lambda handles an SQS event versus Haskell:

javascript
// Node.js - runtime errors waiting to happen
exports.handler = async (event) => {
  for (const record of event.Records) {
    const body = JSON.parse(record.body); // might throw
    const userId = body.userId; // might be undefined
    await processUser(userId); // might get null
  }
};

Versus the Haskell equivalent where the compiler catches structural mismatches:

haskell
data SQSMessage = SQSMessage
  { userId :: Text
  , action :: Action
  } deriving (Generic, FromJSON)

handler :: SQSEvent -> IO SQSResponse
handler event = do
  results <- traverse processRecord (event.records)
  pure $ SQSResponse (catMaybes results)

processRecord :: SQSRecord -> IO (Maybe SQSBatchItemFailure)
processRecord record =
  case Aeson.eitherDecode (record.body) of
    Left err -> do
      logError $ "Parse failed: " <> err
      pure $ Just (SQSBatchItemFailure record.messageId)
    Right (msg :: SQSMessage) ->
      processUser (msg.userId) $> Nothing

The Haskell version makes failure handling explicit at the type level. You can't accidentally ignore a parse failure — the compiler forces you to handle the Either case. That's not just academic nicety; that's the difference between silent data loss and proper error handling in production.

Testing Your Lambda Locally

Before deploying, you want to test locally. The AWS Lambda Runtime Interface Emulator (RIE) lets you invoke your function without deploying it. Here's how to test your Haskell Lambda locally:

bash
# Install the RIE
mkdir -p ~/.aws-lambda-rie
curl -Lo ~/.aws-lambda-rie/aws-lambda-rie \
  https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie
chmod +x ~/.aws-lambda-rie/aws-lambda-rie

# Run your bootstrap binary through the emulator
~/.aws-lambda-rie/aws-lambda-rie ./bootstrap

# In another terminal, invoke it
curl -XPOST "http://localhost:8080/2015-03-31/functions/function/invocations" \
  -d '{"httpMethod": "GET", "path": "/hello"}'

This simulates the Lambda execution environment locally. Your bootstrap binary thinks it's talking to the real Runtime API, but it's actually hitting the emulator. You get realistic behavior without waiting for deployment cycles.

For unit testing the handler logic itself (separate from the Lambda machinery), Haskell's testing ecosystem is mature. HSpec or Tasty with QuickCheck gives you property-based testing that's particularly powerful for data transformation Lambdas — generate thousands of random inputs and verify your invariants hold.

If you're using the container image approach, you can also test the full Docker image:

bash
docker run -p 9000:8080 my-haskell-lambda:latest
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" \
  -d @test-event.json

This catches packaging issues — missing libraries, wrong permissions on the bootstrap binary, environment variable problems — before they hit your deployment pipeline.

Performance and Cold Start Reality

Let's talk numbers. Cold starts for a Haskell Lambda typically land between 150-400ms depending on binary size and initialization logic. Warm invocations are fast — sub-10ms for simple request/response handlers, since you're running compiled native code with no interpreter overhead.

Compare this to the alternatives:

  • Python: ~200ms cold start, but slower warm execution for CPU-bound work
  • Node.js: ~100-300ms cold start, fast warm for I/O-bound work
  • Java: 1-5 seconds cold start (the JVM tax), fast warm
  • Rust/Go: 30-80ms cold start, fast warm

Haskell sits in a comfortable middle ground. You're not getting Rust-level cold starts, but you're getting Rust-level warm performance with a much more expressive type system for business logic.

The real win is correctness per engineering hour. If your Lambda does non-trivial data transformation — parsing complex events, applying business rules, producing structured output — Haskell's type system catches entire categories of bugs at compile time that would be runtime errors in dynamic languages. For teams that already know Haskell, the productivity gain is real.

For teams that don't know Haskell? The learning curve is steep. I wouldn't recommend adopting Haskell specifically for Lambda. But if you're already a Haskell shop, there's no reason to context-switch to Python or Node just because you're going serverless.

What to Actually Do With This

If you want to try this yourself, here's the practical path:

First, grab the aws-lambda-haskell-runtime package. It handles the Runtime API protocol so you don't have to implement the polling loop yourself:

yaml
# package.yaml or cabal file dependency
dependencies:
  - aws-lambda-haskell-runtime >= 4.0
  - aeson >= 2.0
  - text >= 2.0

Set up your project with a clear separation between your business logic (pure functions, easily testable) and the Lambda handler (thin wrapper that deserializes events and calls your logic). This is good architecture regardless of language, but Haskell's type system makes it natural — pure functions can't do I/O, so the separation is enforced by the compiler.

For CI/CD, GitHub Actions with a Haskell Docker builder works well. Cache your Cabal store between builds or you'll be waiting 10+ minutes for dependency compilation on every push. The binary output is deterministic, so you can cache aggressively.

If you're deploying behind API Gateway, configure your response types carefully. API Gateway expects specific response shapes, and Haskell's strict typing means you'll get a compile error if your response doesn't match — which is exactly what you want.

Tip: Use ARM64 (Graviton) Lambda functions if you can. GHC supports aarch64 well, Graviton instances are cheaper, and you'll see slightly better cold start times due to the simpler instruction pipeline.

For monitoring, make sure you're emitting structured logs that CloudWatch can parse. The co-log or katip libraries give you structured logging in Haskell that plays well with CloudWatch Insights queries.

The Bigger Picture

The way I see it — the serverless ecosystem has been dominated by dynamic languages because of developer convenience and fast iteration. But as Lambda workloads mature and move from prototypes to production systems handling real money and real data, the value proposition of strongly-typed compiled languages keeps growing.

Haskell on Lambda is part of a broader trend: Rust on Lambda, Go on Lambda, even OCaml on Lambda. Engineers are realizing that "move fast and break things" doesn't work when your Lambda processes financial transactions or healthcare data. The custom runtime API made this possible, and the tooling is finally catching up to make it practical.

This doesn't mean everyone should rewrite their Python Lambdas in Haskell. That would be absurd. But if you're starting a new serverless project, and your team has functional programming experience, and correctness matters more than time-to-first-deploy — Haskell on Lambda is no longer a novelty. It's a legitimate engineering choice with real production advantages. The lambda calculus running on Lambda. Sometimes the universe has a sense of humor.

Related posts

Written by Eko

If you found this useful, follow @ekofyi on X for more notes like this — or get in touch if you have a problem to solve.