Ξ» AWS Lambda Function Development
AWS Lambda is the most widely used Function-as-a-Service platform in the world. In this lesson you'll write your first handler, understand the execution environment that makes Lambda fast (and occasionally slow), tune memory for cost and speed, wire up event triggers, lock functions down with IAM, and deploy with modern tooling.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Write a Lambda handler that receives an
eventandcontextand returns a response - Explain the init vs. invoke phases and use execution-context reuse to your advantage
- Choose a memory setting understanding that it also scales CPU β and cost
- Connect functions to event sources (API Gateway, S3, DynamoDB Streams, SQS) across the sync, async, and stream invocation models
- Apply least-privilege IAM and use environment variables safely
- Deploy a function with the AWS CLI and with AWS SAM
Estimated Time: 40β55 minutes β’ Difficulty: Intermediate
Hands-on: Build a CRUD "notes" function backed by DynamoDB and deploy it with SAM.
In This Lesson
What Is AWS Lambda?
AWS Lambda runs your code in response to events without any server for you to provision or manage. You upload a function, tell Lambda what should trigger it, and Lambda handles running it β from a handful of calls a day to thousands per second β billing you only for the milliseconds it actually executes.
π‘ Analogy β an on-demand chef. Running your own server is like keeping a restaurant kitchen staffed around the clock: you pay for the space, the ovens, and the cooks whether or not anyone orders. Lambda is a delivery service with on-demand chefs: a chef (a function instance) appears the moment an order (an event) arrives, cooks in isolation, and clocks out when done. Ten orders at once? Ten chefs. No orders? No bill.
The Handler Model
Every Lambda function has a handler: the entry point Lambda calls with two arguments β the event (the payload describing what triggered the function) and the context (runtime metadata like the request ID and remaining time). Here is a modern handler using ES modules on a current Node.js runtime:
// index.mjs β deployed on the nodejs20.x runtime
export const handler = async (event, context) => {
console.log('event:', JSON.stringify(event));
console.log('requestId:', context.awsRequestId);
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hello from Lambda!' }),
};
};
The same idea in Python:
import json
def lambda_handler(event, context):
print("event:", json.dumps(event))
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": "Hello from Lambda!"}),
}
π Key Terms
Runtime: the language environment Lambda provides (e.g. nodejs20.x, python3.12). You can also ship a custom runtime or a container image.
Handler string: tells Lambda which function to call, in the form file.exportedName β e.g. index.handler.
Timeout: the maximum wall-clock time an invocation may run (default 3s, max 15 min).
β οΈ Modernisation note
Older tutorials use require('aws-sdk') (SDK v2) and call .promise() on every operation. On Node.js 18+ runtimes the built-in SDK is v3, which is modular and already promise-based. Prefer import { DynamoDBClient } from '@aws-sdk/client-dynamodb' β that's what the examples below use.
The Execution Environment
Understanding how Lambda runs your code is the difference between a snappy function and a sluggish one. Each invocation goes through two phases:
- Init phase β Lambda creates the execution environment, loads your code, and runs everything outside the handler (imports, client construction, top-level code). This happens on a cold start.
- Invoke phase β Lambda runs the handler itself. On a warm invocation the environment already exists, so only this phase runs.
Because warm environments are reused, anything you set up outside the handler survives between invocations. Do expensive work β building SDK clients, opening connection pools, loading config β once at the module top level:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
// Built ONCE during init and reused by every warm invocation
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event, context) => {
console.log('remaining ms:', context.getRemainingTimeInMillis());
const { Items } = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: 'id = :id',
ExpressionAttributeValues: { ':id': event.id },
}));
return { statusCode: 200, body: JSON.stringify(Items) };
};
π‘ The rule of thumb
Put reusable, stateless setup outside the handler (clients, config). Put per-request logic inside. Never cache user-specific data outside the handler β a warm container may serve a different user next.
Memory, CPU & Cost
Lambda has a single performance dial: memory. Crucially, CPU is allocated proportionally to memory β more memory means a faster vCPU, not just more RAM. That produces a counter-intuitive result: bumping memory up can make a function both faster and cheaper, because it finishes in far less billed time.
π‘ Analogy β choosing a delivery vehicle
128 MB is a bicycle (cheap, slow); 512 MB a scooter (a good all-rounder); 1024 MB a compact car; 3008 MB+ a van for heavy loads. The right choice depends on the size of the job β over-buying wastes money, under-buying wastes time.
Event Sources & Triggers
Lambda functions are invoked in three models, each with different retry and response behaviour.
Synchronous β the caller waits
Used by API Gateway, Application Load Balancer, and direct SDK calls. The caller blocks until your function returns, and your return value is the response. A single handler can route by HTTP method:
export const handler = async (event) => {
const { httpMethod, pathParameters, body } = event;
const id = pathParameters?.id;
let result;
switch (httpMethod) {
case 'GET': result = id ? await getItem(id) : await listItems(); break;
case 'POST': result = await createItem(JSON.parse(body)); break;
case 'PUT': result = await updateItem(id, JSON.parse(body)); break;
case 'DELETE': result = await deleteItem(id); break;
default:
return { statusCode: 405, body: JSON.stringify({ message: 'Method not allowed' }) };
}
return {
statusCode: result.statusCode,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify(result.body),
};
};
Asynchronous β fire and forget
Used by S3 events, SNS, and EventBridge. Lambda queues the event, returns immediately to the caller, and retries automatically on failure (typically twice), routing exhausted events to a Dead Letter Queue if configured. A thrown error here signals "retry me":
// Triggered when a new object lands in an S3 bucket
export const handler = async (event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
if (/\.(jpe?g|png)$/i.test(key)) {
await processImage(bucket, key);
} else {
console.log(`Skipping unsupported file: ${key}`);
}
}
// Throwing here would let Lambda retry the whole event
};
Stream / poll-based β batches of records
Used by DynamoDB Streams, Kinesis, and SQS. Lambda polls the source and delivers batches of records. Your handler loops over event.Records:
import { unmarshall } from '@aws-sdk/util-dynamodb';
export const handler = async (event) => {
for (const record of event.Records) {
const type = record.eventName; // INSERT | MODIFY | REMOVE
const newImage = record.dynamodb.NewImage ? unmarshall(record.dynamodb.NewImage) : null;
if (type === 'INSERT') await onInsert(newImage);
if (type === 'MODIFY') await onUpdate(newImage);
if (type === 'REMOVE') await onDelete(record.dynamodb.Keys);
}
return { processed: event.Records.length };
};
| Model | Examples | Retries? | Response used? |
|---|---|---|---|
| Synchronous | API Gateway, ALB, SDK | Caller's job | Yes β it's the HTTP response |
| Asynchronous | S3, SNS, EventBridge | Yes (auto, then DLQ) | No |
| Stream / poll | DynamoDB Streams, Kinesis, SQS | Yes (batch) | No |
IAM & Configuration
Execution roles & least privilege
Every function runs with an IAM execution role that defines exactly which AWS resources it may touch. Grant only what the function needs β nothing more. This policy lets a function write logs and read/write one specific DynamoDB table:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Notes"
}
]
}
β IAM best practices
- Least privilege: scope actions and resources as tightly as possible.
- One role per function: don't share a broad role across many functions.
- Audit regularly: remove permissions the function no longer uses.
Environment variables & secrets
Use environment variables for configuration β table names, feature flags, endpoints β and give them sensible defaults. Do not store secrets in plain environment variables; fetch them from AWS Secrets Manager or SSM Parameter Store instead.
const stage = process.env.STAGE ?? 'dev';
const table = process.env.TABLE_NAME; // config β fine as an env var
// const dbPassword = process.env.DB_PASSWORD; // β don't do this for secrets
β οΈ VPC access has a cost
Attaching a function to a VPC (to reach a private RDS database, say) is sometimes necessary, but historically added cold-start latency and requires a NAT Gateway for outbound internet. Only put a function in a VPC when it genuinely needs private resources.
Deploying a Function
Quick path β the AWS CLI
For a single function, zip the code and create it directly:
# Package the code
zip function.zip index.mjs
# Create the function
aws lambda create-function \
--function-name hello-notes \
--runtime nodejs20.x \
--role arn:aws:iam::123456789012:role/lambda-notes-role \
--handler index.handler \
--zip-file fileb://function.zip \
--timeout 10 \
--memory-size 256
# Later, ship a code change
aws lambda update-function-code \
--function-name hello-notes \
--zip-file fileb://function.zip
Repeatable path β AWS SAM
Clicking around the console doesn't scale. AWS SAM (Serverless Application Model) is infrastructure-as-code: one template declares the function, its trigger, its permissions, and the table it uses. This is how real projects ship.
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
NotesFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./src/
Handler: index.handler
Runtime: nodejs20.x
MemorySize: 256
Timeout: 10
Environment:
Variables:
TABLE_NAME: !Ref NotesTable
Policies:
- DynamoDBCrudPolicy: # scoped managed policy β least privilege
TableName: !Ref NotesTable
Events:
Api:
Type: Api
Properties:
Path: /notes
Method: get
NotesTable:
Type: AWS::Serverless::SimpleTable
Properties:
PrimaryKey:
Name: id
Type: String
Outputs:
ApiUrl:
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/notes/"
# Build, then deploy interactively the first time
sam build
sam deploy --guided
Hands-on Exercise
ποΈ Build a "Create Note" Function
Objective: Write a Lambda handler that validates input, generates an ID, and stores a note in DynamoDB β applying the init/invoke and least-privilege lessons.
Requirements
- Build the DynamoDB client once, outside the handler.
- Parse and validate the request body; reject a missing
titlewith a 400. - Generate a UUID, add
createdAt, and put the item in the table named byprocess.env.TABLE_NAME. - Return 201 with the created note; return 500 on unexpected errors.
π‘ Hint
Use @aws-sdk/lib-dynamodb's PutCommand so you can pass a plain JS object without marshalling. Node 20 has a built-in crypto.randomUUID() β no extra dependency needed.
β Example solution
import { randomUUID } from 'node:crypto';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
// init phase β reused by warm invocations
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event) => {
try {
const body = JSON.parse(event.body ?? '{}');
if (!body.title || typeof body.title !== 'string') {
return { statusCode: 400, body: JSON.stringify({ message: 'title is required' }) };
}
const now = new Date().toISOString();
const note = {
id: randomUUID(),
title: body.title,
content: body.content ?? '',
completed: false,
createdAt: now,
updatedAt: now,
};
await ddb.send(new PutCommand({ TableName: TABLE, Item: note }));
return { statusCode: 201, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note) };
} catch (err) {
console.error('createNote failed:', err);
return { statusCode: 500, body: JSON.stringify({ message: 'Could not create note' }) };
}
};
Deploy it by adding a POST /notes event to the SAM template from the previous section, then sam build && sam deploy.
Quiz
π― Check Your Understanding
Question 1: Why should you construct SDK clients outside the handler function?
Question 2: Increasing a Lambda function's memory setting alsoβ¦
Question 3: A function triggered asynchronously by an S3 event throws an error. What happens?
Summary & Next Steps
π Key Takeaways
- A Lambda handler receives
event+contextand returns a response. - The environment has an init phase (cold start, reused when warm) and an invoke phase β put reusable setup outside the handler.
- Memory scales CPU, so more memory can be faster and cheaper; tune to the knee of the curve.
- Triggers come in synchronous, asynchronous, and stream models with different retry semantics.
- Use least-privilege IAM roles, keep secrets in Secrets Manager/SSM, and deploy with SAM for repeatability.
π Further Reading
π What's Next?
Writing functions one at a time gets tedious fast. Next, in Building APIs with the Serverless Framework, you'll wire many functions, a database, and an API together from a single config file β and deploy the whole stack with one command.