Building REST APIs with AWS Lambda and API Gateway

Introduction

Serverless computing has transformed how developers build and deploy applications in the cloud. By abstracting away server management, auto-scaling, and capacity planning, serverless architectures allow teams to focus purely on writing business logic. Among the most popular serverless services, AWS Lambda combined with Amazon API Gateway provides a powerful, scalable, and cost-effective foundation for building REST APIs.

In this tutorial, you’ll learn how to build, deploy, and manage a fully functional serverless REST API from scratch. We’ll cover everything from setting up your first Lambda function to implementing authentication, validation, and CI/CD pipelines — all using Infrastructure as Code principles.

Prerequisites
An AWS account (free tier eligible), AWS CLI configured with appropriate credentials, Node.js 18+ or Python 3.9+, and basic familiarity with REST API concepts.

Serverless REST API Architecture

HTTP Client Amazon API Gateway REST API AWS Lambda Business Logic Amazon DynamoDB NoSQL Data Store GET /items POST /items CRUD Operations AWS CloudWatch Monitoring AWS IAM Auth & Permissions Managed Services

Why Serverless for REST APIs?

Traditional REST APIs require provisioning EC2 instances, configuring load balancers, managing autoscaling groups, and handling OS patching. Serverless eliminates this overhead:

  • Zero scaling overhead: Lambda scales automatically from zero to thousands of concurrent executions
  • Pay-per-use pricing: You pay only for compute time consumed — no cost when your API is idle
  • Reduced operational burden: No servers to patch, SSH into, or monitor at the OS level
  • Built-in high availability: Lambda functions run across multiple Availability Zones by default
  • Ecosystem integration: Native integration with DynamoDB, SQS, SNS, S3, Cognito, and dozens of other AWS services

Architecture Overview

Our serverless REST API will follow this architecture:

Client (HTTP Request)
    │
    ▼
Amazon API Gateway (REST API)
    │
    ├── /items (GET, POST)
    ├── /items/{id} (GET, PUT, DELETE)
    │
    ▼
AWS Lambda Functions (Business Logic)
    │
    ▼
Amazon DynamoDB (Data Store)

API Gateway serves as the entry point, routing HTTP requests to individual Lambda functions. Each Lambda function performs CRUD operations against a DynamoDB table. This pattern is commonly called the “Lambda Service” architecture and is ideal for microservices.

Step 1: Creating the DynamoDB Table

Let’s start with the data layer. We’ll use DynamoDB — a fully managed NoSQL key-value and document database.

Using AWS CLI

aws dynamodb create-table \
    --table-name Items \
    --attribute-definitions AttributeName=id,AttributeType=S \
    --key-schema AttributeName=id,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region us-east-1

Key decisions here:

  • PAY_PER_REQUEST (on-demand): Best for variable or unpredictable traffic. No capacity planning needed.
  • id as partition key: Using UUID strings as unique identifiers for each item.
  • No sort key needed: Each item has a unique ID; no need for hierarchical data storage.

Step 2: Writing the Lambda Function

We’ll write a single Lambda handler that routes requests based on the HTTP method and path. This is known as the “fat lambda” or “handler routing” pattern — suitable for small to medium APIs.

Python (3.9+) — lambda_function.py

import json
import os
import uuid
import boto3
from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ.get('TABLE_NAME', 'Items'))

def lambda_handler(event, context):
    print(f"Event: {json.dumps(event)}")
    
    http_method = event.get('httpMethod', '')
    resource = event.get('resource', '')
    path_params = event.get('pathParameters') or {}
    item_id = path_params.get('id')
    
    headers = {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type,Authorization'
    }
    
    try:
        if http_method == 'GET' and resource == '/items':
            return get_all_items(headers)
        elif http_method == 'GET' and resource == '/items/{id}':
            return get_item(item_id, headers)
        elif http_method == 'POST' and resource == '/items':
            body = json.loads(event.get('body', '{}'))
            return create_item(body, headers)
        elif http_method == 'PUT' and resource == '/items/{id}':
            body = json.loads(event.get('body', '{}'))
            return update_item(item_id, body, headers)
        elif http_method == 'DELETE' and resource == '/items/{id}':
            return delete_item(item_id, headers)
        elif http_method == 'OPTIONS':
            return build_response(200, {}, headers)
        else:
            return build_response(400, {'error': 'Unsupported route'}, headers)
    except Exception as e:
        print(f"Error: {str(e)}")
        return build_response(500, {'error': 'Internal server error'}, headers)

def get_all_items(headers):
    response = table.scan()
    return build_response(200, {'items': response.get('Items', [])}, headers)

def get_item(item_id, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    response = table.get_item(Key={'id': item_id})
    if 'Item' not in response:
        return build_response(404, {'error': 'Item not found'}, headers)
    return build_response(200, response['Item'], headers)

def create_item(body, headers):
    if 'name' not in body:
        return build_response(400, {'error': 'name is required'}, headers)
    
    item = {
        'id': str(uuid.uuid4()),
        'name': body['name'],
        'description': body.get('description', ''),
        'created_at': datetime.utcnow().isoformat(),
        'updated_at': datetime.utcnow().isoformat()
    }
    table.put_item(Item=item)
    return build_response(201, item, headers)

def update_item(item_id, body, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    
    update_expression = 'SET #name = :name, description = :desc, updated_at = :ts'
    expression_attrs = {
        '#name': 'name'
    }
    attr_values = {
        ':name': body.get('name'),
        ':desc': body.get('description', ''),
        ':ts': datetime.utcnow().isoformat()
    }
    
    response = table.update_item(
        Key={'id': item_id},
        UpdateExpression=update_expression,
        ExpressionAttributeNames=expression_attrs,
        ExpressionAttributeValues=attr_values,
        ReturnValues='ALL_NEW'
    )
    return build_response(200, response.get('Attributes', {}), headers)

def delete_item(item_id, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    table.delete_item(Key={'id': item_id})
    return build_response(204, {}, headers)

def build_response(status_code, body, headers):
    return {
        'statusCode': status_code,
        'headers': headers,
        'body': json.dumps(body) if status_code != 204 else ''
    }

Node.js 18+ — index.mjs

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Items';

const headers = {
  'Content-Type': 'application/json',
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type,Authorization',
};

export const handler = async (event) => {
  console.log('Event:', JSON.stringify(event));
  const { httpMethod, resource, pathParameters } = event;
  const itemId = pathParameters?.id;
  
  try {
    switch (`${httpMethod} ${resource}`) {
      case 'GET /items':
        return await getAllItems();
      case 'GET /items/{id}':
        return await getItem(itemId);
      case 'POST /items':
        return await createItem(JSON.parse(event.body || '{}'));
      case 'PUT /items/{id}':
        return await updateItem(itemId, JSON.parse(event.body || '{}'));
      case 'DELETE /items/{id}':
        return await deleteItem(itemId);
      case 'OPTIONS /items':
      case 'OPTIONS /items/{id}':
        return buildResponse(200, {});
      default:
        return buildResponse(400, { error: 'Unsupported route' });
    }
  } catch (error) {
    console.error('Error:', error);
    return buildResponse(500, { error: 'Internal server error' });
  }
};

async function getAllItems() {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  return buildResponse(200, { items: result.Items || [] });
}

async function getItem(id) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  const result = await docClient.send(new GetCommand({ TableName: TABLE_NAME, Key: { id } }));
  if (!result.Item) return buildResponse(404, { error: 'Item not found' });
  return buildResponse(200, result.Item);
}

async function createItem(body) {
  if (!body.name) return buildResponse(400, { error: 'name is required' });
  const now = new Date().toISOString();
  const item = {
    id: uuidv4(),
    name: body.name,
    description: body.description || '',
    createdAt: now,
    updatedAt: now,
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: item }));
  return buildResponse(201, item);
}

async function updateItem(id, body) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  const now = new Date().toISOString();
  const result = await docClient.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { id },
    UpdateExpression: 'SET #n = :name, description = :desc, updatedAt = :ts',
    ExpressionAttributeNames: { '#n': 'name' },
    ExpressionAttributeValues: { ':name': body.name, ':desc': body.description || '', ':ts': now },
    ReturnValues: 'ALL_NEW',
  }));
  return buildResponse(200, result.Attributes);
}

async function deleteItem(id) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  await docClient.send(new DeleteCommand({ TableName: TABLE_NAME, Key: { id } }));
  return buildResponse(204, {});
}

function buildResponse(statusCode, body) {
  return {
    statusCode,
    headers,
    body: statusCode === 204 ? '' : JSON.stringify(body),
  };
}
Tip
Always include CORS headers (Access-Control-Allow-Origin) in your Lambda response when the API is consumed by web browsers. For production, restrict the origin to your actual domain instead of using a wildcard.

Step 3: Packaging and Deploying the Lambda

Python Deployment Package

# Create a deployment package
mkdir -p build
pip install boto3 -t build/
cp lambda_function.py build/
cd build
zip -r ../function.zip .
cd ..
aws lambda create-function \
    --function-name items-api \
    --runtime python3.12 \
    --handler lambda_function.lambda_handler \
    --role arn:aws:iam::YOUR_ACCOUNT:role/lambda-dynamodb-role \
    --zip-file fileb://function.zip \
    --environment Variables={TABLE_NAME=Items} \
    --region us-east-1

Node.js Deployment Package

npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb uuid
zip -r function.zip index.mjs package.json node_modules/

aws lambda create-function \
    --function-name items-api \
    --runtime nodejs18.x \
    --handler index.handler \
    --role arn:aws:iam::YOUR_ACCOUNT:role/lambda-dynamodb-role \
    --zip-file fileb://function.zip \
    --environment Variables={TABLE_NAME=Items} \
    --region us-east-1

Step 4: IAM Role Configuration

The Lambda function needs an execution role with permissions to access DynamoDB. Create the trust policy and attach the required permissions:

# Create trust policy document for Lambda
cat > trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
    --role-name lambda-dynamodb-role \
    --assume-role-policy-document file://trust-policy.json

# Attach DynamoDB access policy
aws iam attach-role-policy \
    --role-name lambda-dynamodb-role \
    --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess

# Attach CloudWatch logs policy (essential for debugging)
aws iam attach-role-policy \
    --role-name lambda-dynamodb-role \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Security Note
For production, follow the principle of least privilege. Instead of AmazonDynamoDBFullAccess, create a custom policy scoped to specific table ARN and actions (GetItem, PutItem, UpdateItem, DeleteItem, Scan).

Step 5: Setting Up API Gateway

API Gateway acts as the HTTP front door. We'll create a REST API with resources and methods that map to our Lambda function.

Using AWS Console (Manual Setup)

While we'll automate everything eventually, understanding the manual process helps with debugging:

  1. Open API Gateway console → Create API → REST API (not HTTP API)
  2. Choose "New API" → Name it items-api → Regional endpoint
  3. Create resource: /items → Enable CORS
  4. Create resource: /items/{id} → Enable CORS
  5. For each resource, create methods: GET, POST, PUT, DELETE, OPTIONS
  6. Integrate each method with your Lambda function (Lambda Proxy integration = ON)
  7. Deploy API to a stage (e.g., prod)

Using AWS CLI (Automated)

# Create the REST API
API_ID=$(aws apigateway create-rest-api \
    --name "items-api" \
    --region us-east-1 \
    --query 'id' --output text)

# Get the root resource ID
ROOT_ID=$(aws apigateway get-resources \
    --rest-api-id $API_ID \
    --region us-east-1 \
    --query 'items[0].id' --output text)

# Create /items resource
ITEMS_ID=$(aws apigateway create-resource \
    --rest-api-id $API_ID \
    --parent-id $ROOT_ID \
    --path-part "items" \
    --region us-east-1 \
    --query 'id' --output text)

# Create /items/{id} resource
ITEM_ID=$(aws apigateway create-resource \
    --rest-api-id $API_ID \
    --parent-id $ITEMS_ID \
    --path-part "{id}" \
    --region us-east-1 \
    --query 'id' --output text)

# Create GET /items method with Lambda integration
aws apigateway put-method \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --http-method GET \
    --authorization-type NONE \
    --region us-east-1

aws apigateway put-integration \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --http-method GET \
    --type AWS_PROXY \
    --integration-http-method POST \
    --uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:YOUR_ACCOUNT:function:items-api/invocations \
    --region us-east-1

# Deploy to prod
aws apigateway create-deployment \
    --rest-api-id $API_ID \
    --stage-name prod \
    --region us-east-1

echo "API endpoint: https://${API_ID}.execute-api.us-east-1.amazonaws.com/prod/items"

API Request Lifecycle

Step 1 HTTP Request

Step 2 API Gateway

Step 3 Lambda Handler

Step 4 Router

Create PUT Item

Read GET Item(s)

Update PUT Item

Delete DELETE Item

Amazon DynamoDB NoSQL Key-Value Store

JSON Response

AWS Managed Lambda Logic

Step 6: Testing Your API

Once deployed, test each endpoint using curl or any HTTP client:

# Replace with your actual API endpoint
API_URL="https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/prod"

# Create an item (POST)
curl -X POST "$API_URL/items" \
  -H "Content-Type: application/json" \
  -d '{"name": "Serverless Guide", "description": "A comprehensive tutorial"}'

# Response:
# {"id":"abc-123","name":"Serverless Guide","description":"A comprehensive tutorial","created_at":"2025-01-15T10:30:00","updated_at":"2025-01-15T10:30:00"}

# Get all items (GET)
curl "$API_URL/items"

# Get a single item (GET)
curl "$API_URL/items/abc-123"

# Update an item (PUT)
curl -X PUT "$API_URL/items/abc-123" \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Guide", "description": "Updated description"}'

# Delete an item (DELETE)
curl -X DELETE "$API_URL/items/abc-123"

Step 7: Adding API Key Authentication

For production APIs, you'll want to restrict access. API Gateway supports several authentication mechanisms. Here's how to add API key authentication:

# Create an API key
aws apigateway create-api-key \
    --name "production-key" \
    --enabled \
    --region us-east-1 \
    --query 'value' --output text

# Create a usage plan
aws apigateway create-usage-plan \
    --name "basic-plan" \
    --description "1000 requests per month" \
    --api-stages "[{\"apiId\": \"$API_ID\", \"stage\": \"prod\"}]" \
    --throttle burstLimit=20,rateLimit=10 \
    --quota limit=1000,period=MONTH \
    --region us-east-1

# Associate key with plan
USAGE_PLAN_ID=$(aws apigateway get-usage-plans --query 'items[?name==`basic-plan`].id' --output text)
API_KEY_ID=$(aws apigateway get-api-keys --name-query "production-key" --query 'items[0].id' --output text)

aws apigateway create-usage-plan-key \
    --usage-plan-id $USAGE_PLAN_ID \
    --key-id $API_KEY_ID \
    --key-type API_KEY \
    --region us-east-1

# Now require API key on methods
aws apigateway update-method \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --http-method GET \
    --patch-operations op=replace,path=/apiKeyRequired,value=true \
    --region us-east-1

# Deploy again (changes require redeployment)
aws apigateway create-deployment \
    --rest-api-id $API_ID \
    --stage-name prod \
    --region us-east-1

Clients must now include the API key in their requests:

curl -H "x-api-key: YOUR_API_KEY" "$API_URL/items"
Production Tip
API key authentication is the simplest option. For user-facing applications, consider Amazon Cognito User Pools for JWT-based authentication, or Lambda authorizers for custom auth logic.

Step 8: Observability with CloudWatch

Serverless doesn't mean "no ops." Monitoring is critical. AWS Lambda automatically emits metrics and logs to CloudWatch:

Key Metrics to Monitor

Metric What It Tells You Alert Threshold
Invocations Request volume over time Spike alerting for anomalies
Duration How long functions take to execute P99 > 5 seconds
Errors Failed invocations > 1% error rate
Throttles Concurrency limit exceeded Any throttle event
IteratorAge Stream-based invocations lag > 1 hour
# Create a CloudWatch alarm for error rate
aws cloudwatch put-metric-alarm \
    --alarm-name items-api-errors \
    --alarm-description "Alert when error count exceeds threshold" \
    --metric-name Errors \
    --namespace AWS/Lambda \
    --statistic Sum \
    --period 300 \
    --threshold 5 \
    --comparison-operator GreaterThanThreshold \
    --dimensions Name=FunctionName,Value=items-api \
    --evaluation-periods 2 \
    --alarm-actions arn:aws:sns:us-east-1:YOUR_ACCOUNT:ops-topic

Step 9: Automating with Infrastructure as Code

For repeatable, version-controlled deployments, use the Serverless Application Model (SAM) or Terraform. Here's a SAM template:

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless Items API

Globals:
  Function:
    Timeout: 10
    MemorySize: 128
    Runtime: python3.12
    Environment:
      Variables:
        TABLE_NAME: !Ref ItemsTable

Resources:
  ItemsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"

  ItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: lambda_function.lambda_handler
      Events:
        GetItems:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items
            Method: GET
        CreateItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items
            Method: POST
        GetItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: GET
        UpdateItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: PUT
        DeleteItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: DELETE
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ItemsTable

  ItemsTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: id
        Type: String
      BillingMode: PAY_PER_REQUEST

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ItemsApi}.execute-api.${AWS::Region}.amazonaws.com/prod/items"

Deploy with:

sam build
sam deploy --guided  # First time only
sam deploy           # Subsequent deployments

Step 10: Cold Starts and Performance Optimization

A common criticism of serverless is the "cold start" — the latency when Lambda initializes a new execution environment. Here are proven strategies to mitigate this:

  • Provisioned Concurrency: Keep N execution environments warm for predictable latency. Adds cost but eliminates cold starts.
  • Reserve Concurrency: Prevents your function from being throttled during traffic spikes.
  • Choose faster runtimes: Python and Node.js have sub-second cold starts (~200-400ms). Java and .NET can take 2-6 seconds.
  • Reduce deployment package size: Smaller zips = faster initialization. Use Lambda Layers for shared dependencies.
  • Lazy-load connections: Initialize SDK clients and DB connections outside the handler (reused across warm invocations).
# Enable provisioned concurrency (AWS CLI)
aws lambda put-provisioned-concurrency-config \
    --function-name items-api \
    --qualifier prod \
    --provisioned-concurrent-executions 5 \
    --region us-east-1
Pro Tip
Combine Provisioned Concurrency with Application Auto Scaling to dynamically adjust warm concurrency based on traffic patterns — you pay only for what you use, but never get caught off-guard by a traffic spike.

Conclusion

You've now built a fully functional serverless REST API with AWS Lambda, API Gateway, and DynamoDB. The architecture we've implemented handles auto-scaling, pay-per-use pricing, and high availability without any server management.

Key takeaways from this tutorial:

  • Serverless REST APIs eliminate infrastructure management while providing enterprise-grade scalability
  • API Gateway + Lambda + DynamoDB forms a powerful "three-tier serverless" pattern
  • Always implement authentication, validation, and observability from day one
  • Use Infrastructure as Code ( SAM, Terraform, CDK) for repeatable deployments
  • Optimize for cold starts with Provisioned Concurrency and efficient runtimes

In your next serverless project, consider adding AWS Step Functions for orchestration, EventBridge for event-driven integrations, and Lambda@Edge for running code at CloudFront edge locations. The serverless ecosystem is vast and continues to grow — but the fundamentals you've learned today will serve as a solid foundation for any serverless architecture.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top