If you have been building on AWS by clicking through the console (also called ClickOps), AWS CDK is the upgrade you need. Instead of manually creating resources every time, you write code that describes your entire infrastructure, and CDK deploys it for you. Every time. Consistently. Without mistakes.
In this article, I will walk you through building a complete serverless CRUD API on AWS using CDK and Python. By the end, you will have a real API endpoint that can create, read, update, and delete items in a DynamoDB table, all provisioned with just a few Python files and a single deploy command.
What is AWS CDK and Why Should You Care?
Before CDK, Infrastructure as Code (IaC) on AWS was done using CloudFormation, where you had to write long YAML or JSON files to describe every resource. It worked, but it was painful. You had to learn a new syntax and even creating one simple Lambda function required dozens of lines of configuration.
CDK (Cloud Development Kit) solves this. You write infrastructure in a real programming language (Python, TypeScript, Java, Go, etc.), and CDK converts it into CloudFormation under the hood. You get all the power of a programming language (loops, conditions, functions, type checking) applied to your infrastructure.
One concept to understand before we start:
Stack: A stack is a group of related AWS resources that are deployed and managed together. Think of it as one deployment unit. Everything you create in a stack gets deployed at once, and destroyed at once.
Here is the architecture we are going to build:
Prerequisites
- AWS CLI installed and configured (
aws configure) - Python 3.8 or higher
- Node.js installed (CDK runs on Node.js internally)
- AWS CDK installed:
npm install -g aws-cdk
Step 1: Initialize the CDK Project
Create a new directory and initialize a CDK project in Python:
mkdir cdkTutorial_crudApi && cd cdkTutorial_crudApi
cdk init app --language python
This generates a project with several files. The important one is the stack file (named something like cdk_tutorial_crud_api/cdk_tutorial_crud_api_stack.py). This is where you will define all your AWS resources.
Also create a lambda/ folder at the root of the project. This is where the Lambda function code will live:
mkdir lambda
Now activate your virtual environment and install the dependencies listed in requirements.txt:
# On Mac/Linux
source .venv/bin/activate
# On Windows
.venv\Scripts\activate
pip install -r requirements.txt
Step 2: Bootstrap Your AWS Account
Before deploying anything with CDK, you need to run this command once per AWS account and region:
cdk bootstrap
What this does: it creates an S3 bucket in your account that CDK uses to store deployment assets (like your zipped Lambda code). When you later run cdk deploy, CDK uploads those assets to that bucket and deploys everything from there.
The great advantage of this: if you push your code to GitHub and come back a year later, running cdk deploy will recreate the exact same infrastructure without any manual steps or changes.
Step 3: Create the DynamoDB Table
The first resource we will create is the DynamoDB table. This is the database that will store all items our API will manage. Open your stack file and add the following code:
from aws_cdk import (
Stack,
RemovalPolicy, # Controls what happens to resources when you delete the stack
CfnOutput, # Prints useful values after deployment
aws_dynamodb as dynamodb,
)
from constructs import Construct # The base class for all CDK resources
class CdkTutorialCrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
# Pass any extra settings (like AWS region) to the parent Stack class
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
# Create a DynamoDB table to store our items
items_table = dynamodb.Table(
self, # means: put this construct inside the current stack
"ItemsTable", # construct ID within the stack (not the actual table name)
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# --- OUTPUTS ---
# Print the table name in the terminal after deployment to confirm it was created
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)
Key things to understand about this stack
Stack and Construct:
Stack is the base class that groups all your AWS resources together as one deployment unit. Construct is the base class for every individual resource inside a stack. When you see self passed as the first argument to a resource, it means "put this resource inside the current stack."
dynamodb.Table vs dynamodb.TableV2:
CDK gives you two constructs for DynamoDB. dynamodb.Table is the standard one we use here. dynamodb.TableV2 is a newer version with enhanced features like native support for Global Tables (multi-region replication). For new production infrastructure, prefer TableV2. For learning, Table is perfectly fine.
"ItemsTable" is the construct ID, not the table name:
Every resource in CDK needs a unique ID within the stack. "ItemsTable" is that ID. It is not the actual DynamoDB table name in AWS. If you want to give the table a specific name in AWS, you add table_name="your-table-name" as a separate argument. If you do not set it, CloudFormation generates a unique name automatically.
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST:
This means you only pay when the table is actually read from or written to. There is no capacity to pre-provision. For a tutorial like this, it is the most cost-effective option.
removal_policy=RemovalPolicy.DESTROY:
This controls what happens to the table when you run cdk destroy. DESTROY means the table (and all its data) gets deleted along with the stack. This is the right setting for a learning project. In production, you would use RemovalPolicy.RETAIN to protect your data from accidental deletion.
CfnOutput:
After CDK deploys your stack, CfnOutput prints values in the terminal so you can confirm the resources were created. Here it prints the actual table name that CloudFormation assigned after deployment. items_table.table_name is a CDK reference that resolves to the real name once CloudFormation finishes creating the table.
Deploy the DynamoDB table
Before deploying, always run cdk diff first. This command compares what is currently in your AWS account with what your stack is about to deploy, and shows you exactly what will be added, modified, or removed. It is a safety check before making any real changes to AWS.
cdk diff
Once you are satisfied with what cdk diff shows, deploy:
cdk deploy
After the deployment finishes, check the terminal output. You should see the TableName output printed, confirming the table was created. You can also verify it in the AWS console by going to DynamoDB and looking for the table.
Step 4: Write the Lambda Function
Now we write the Lambda function that will handle all the CRUD operations. Inside the lambda/ folder, create a file called index.py and paste the code below.
This single file handles every HTTP route. API Gateway sends every request to this one function, and the function routes it to the correct operation based on the HTTP method and path.
import json
import os
import uuid
import boto3 # AWS SDK for Python, pre-installed in every Lambda runtime
# Connect to DynamoDB using the table name passed in as an environment variable.
# CDK sets this variable automatically when it creates the Lambda function (you will see how in Step 5).
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def handler(event, context):
"""
Main entry point for the Lambda function.
API Gateway sends an 'event' dict containing the HTTP method, path,
body, and any path parameters. This function routes to the right
operation based on the HTTP method and path.
"""
http_method = event["httpMethod"] # "GET", "POST", "PUT", or "DELETE"
path = event.get("resource", "") # "/items" or "/items/{id}"
try:
# Route to the correct handler based on HTTP method and path
if http_method == "GET" and path == "/items":
return get_all_items()
elif http_method == "GET" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
return get_item(item_id)
elif http_method == "POST" and path == "/items":
body = json.loads(event["body"])
return create_item(body)
elif http_method == "PUT" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
body = json.loads(event["body"])
return update_item(item_id, body)
elif http_method == "DELETE" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
return delete_item(item_id)
# If none of the routes matched, return 400
return api_response(400, {"error": "Unsupported route"})
except Exception as e:
return api_response(500, {"error": str(e)})
def get_all_items():
"""Scan the table and return all items."""
result = table.scan()
return api_response(200, result["Items"])
def get_item(item_id):
"""Fetch a single item by its id."""
result = table.get_item(Key={"id": item_id})
if "Item" not in result:
return api_response(404, {"error": f"Item {item_id} not found"})
return api_response(200, result["Item"])
def create_item(body):
"""Create a new item with a generated UUID."""
item = {
"id": str(uuid.uuid4()), # Generate a unique id automatically
"name": body.get("name", ""),
"description": body.get("description", ""),
}
table.put_item(Item=item)
return api_response(201, item)
def update_item(item_id, body):
"""Update an existing item's name and description."""
result = table.update_item(
Key={"id": item_id},
# UpdateExpression is DynamoDB's syntax for partial updates.
# SET means "change these fields". :n and :d are placeholders
# that map to the values in ExpressionAttributeValues below.
UpdateExpression="SET #n = :n, description = :d",
# "name" is a reserved word in DynamoDB, so we use #n as an alias
ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={
":n": body.get("name", ""),
":d": body.get("description", ""),
},
ReturnValues="ALL_NEW", # Return the full item after the update
)
return api_response(200, result["Attributes"])
def delete_item(item_id):
"""Delete an item by its id."""
table.delete_item(Key={"id": item_id})
return api_response(200, {"message": f"Deleted {item_id}"})
def api_response(status_code, body):
"""
Build the response dict that API Gateway expects.
API Gateway requires this exact format: statusCode, headers, and body.
The body must be a JSON string, not a Python dict.
"""
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
# Allow requests from any origin (fine for a tutorial,
# restrict this to your frontend domain in production)
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps(body, default=str), # default=str handles DynamoDB Decimal types
}
Step 5: Add the Lambda Function to the Stack
Now go back to the stack file and add the Lambda construct. You are not replacing the DynamoDB code from Step 3, you are adding the Lambda construct below it. Replace the full content of your stack file with the code below:
from aws_cdk import (
Stack,
RemovalPolicy, # Controls what happens to resources when you delete the stack
CfnOutput, # Prints useful values after deployment
Duration, # Used to set the Lambda timeout
aws_dynamodb as dynamodb,
aws_lambda as _lambda,
)
from constructs import Construct
class CdkTutorialCrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
items_table = dynamodb.Table(
self,
"ItemsTable",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# --- LAMBDA FUNCTION ---
api_handler = _lambda.Function(
self,
"MyFunction",
runtime=_lambda.Runtime.PYTHON_3_13, # Python version to use
handler="index.handler", # Run the handler() function in index.py
code=_lambda.Code.from_asset("lambda"), # Zip and upload the lambda/ folder
architecture=_lambda.Architecture.ARM_64, # Graviton: cheaper and faster than x86
environment={
# Pass the table name to the Lambda as an environment variable.
# This is how index.py knows which table to connect to.
"TABLE_NAME": items_table.table_name,
},
timeout=Duration.seconds(10), # Default is 3s, which is too short for DynamoDB calls
memory_size=128,
)
# --- PERMISSIONS ---
# Grant the Lambda function permission to read and write to the DynamoDB table.
# Without this line, every API call returns AccessDeniedException.
items_table.grant_read_write_data(api_handler)
# --- OUTPUTS ---
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)
Key things to understand about the Lambda construct
handler="index.handler":
This tells Lambda which function to run when it is invoked. The format is filename.function_name. So "index.handler" means: find index.py in the uploaded code, then call the handler() function inside it. If your file is named differently or your function has a different name, this line must match.
code=_lambda.Code.from_asset("lambda"):
CDK zips the entire lambda/ folder and uploads it to the S3 bootstrap bucket. When you deploy, Lambda pulls from that bucket. This is why the cdk bootstrap step was necessary.
architecture=_lambda.Architecture.ARM_64:
This runs the Lambda on AWS Graviton (ARM) processors instead of x86. Graviton is generally cheaper and faster for most workloads. Unless you have a specific reason to use x86, ARM_64 is the better default.
environment={"TABLE_NAME": items_table.table_name}:
This is how the Lambda function knows which DynamoDB table to connect to. Instead of hardcoding the table name inside index.py, we pass it as an environment variable from the stack. This is the right approach because the actual table name is generated by CloudFormation at deploy time and is not known until then. In index.py, the function reads this with os.environ["TABLE_NAME"].
timeout=Duration.seconds(10):
Lambda's default timeout is 3 seconds. DynamoDB calls can sometimes take longer than that, especially on cold starts. Setting it to 10 seconds gives the function enough room to complete without timing out unexpectedly.
items_table.grant_read_write_data(api_handler):
This is one of the most important lines in the entire stack. CDK automatically generates the correct IAM policy and attaches it to the Lambda's execution role, giving it permission to perform GetItem, PutItem, UpdateItem, DeleteItem, and Scan on the table. Without this, every single API call will fail with AccessDeniedException.
Deploy the stack with Lambda
Run cdk diff to preview what is about to change. Since you already deployed the DynamoDB table, this will show only the new Lambda-related resources being added:
cdk diff
Then deploy:
cdk deploy
After deployment:
After deployment, verify the Lambda function exists:
aws lambda list-functions --query "Functions[?starts_with(FunctionName, 'CrudApiStack')].FunctionName" --output text
Step 6: Add the API Gateway
Now we add the final piece: the API Gateway that exposes our Lambda function as HTTP endpoints. Replace the full content of your stack file with this final version:
from aws_cdk import (
Stack,
RemovalPolicy,
CfnOutput,
Duration,
aws_dynamodb as dynamodb,
aws_lambda as _lambda,
aws_apigateway as apigw,
)
from constructs import Construct
class CdkTutorialCrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
items_table = dynamodb.Table(
self,
"ItemsTable",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# --- LAMBDA FUNCTION ---
api_handler = _lambda.Function(
self,
"MyFunction",
runtime=_lambda.Runtime.PYTHON_3_13,
handler="index.handler",
code=_lambda.Code.from_asset("lambda"),
architecture=_lambda.Architecture.ARM_64,
environment={
"TABLE_NAME": items_table.table_name,
},
timeout=Duration.seconds(10),
memory_size=128,
)
# --- API GATEWAY ---
api_endpoints = apigw.LambdaRestApi(
self,
"itemsApi",
handler=api_handler, # Which Lambda to send requests to
proxy=False, # We define routes manually (no catch-all)
rest_api_name="Items Service",
description="CRUD API for items, built with CDK",
default_cors_preflight_options=apigw.CorsOptions(
allow_origins=apigw.Cors.ALL_ORIGINS,
allow_methods=apigw.Cors.ALL_METHODS,
allow_headers=apigw.Cors.DEFAULT_HEADERS,
),
)
# Define the routes (resources and methods)
# /items
items = api_endpoints.root.add_resource("items")
items.add_method("GET") # GET /items -> get all items
items.add_method("POST") # POST /items -> create an item
# /items/{id}
item = items.add_resource("{id}")
item.add_method("GET") # GET /items/{id} -> get one item
item.add_method("PUT") # PUT /items/{id} -> update an item
item.add_method("DELETE") # DELETE /items/{id} -> delete an item
# --- PERMISSIONS ---
items_table.grant_read_write_data(api_handler)
# --- OUTPUTS ---
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)
CfnOutput(
self,
"ApiUrl",
value=api_endpoints.url,
description="API Gateway endpoint URL",
)
Key things to understand about the API Gateway
apigw.LambdaRestApi vs apigw.RestApi:
LambdaRestApi is a higher-level construct that automatically connects API Gateway to a Lambda function. You just specify handler=api_handler and CDK handles all the integration wiring behind the scenes (permissions, Lambda invocation setup). RestApi is the lower-level version that gives you more granular control, for example when different routes need to go to different Lambda functions. For this project where one Lambda handles everything, LambdaRestApi is the right choice.
proxy=False:
By default, LambdaRestApi creates a greedy catch-all route (ANY /{proxy+}) that forwards all incoming requests to Lambda without you defining any routes. Setting proxy=False disables that and allows you to explicitly define each route and method yourself using add_resource() and add_method(). This is the better approach when you want control over exactly which HTTP methods are allowed on each path.
Important: this
proxysetting is different from Lambda Proxy Integration.LambdaRestApialways uses Lambda Proxy Integration regardless of whetherproxyisTrueorFalse. Lambda Proxy Integration means API Gateway forwards the full HTTP request (method, headers, body, path parameters) to Lambda as a JSON event object, and Lambda returns the full response includingstatusCode,headers, andbody. That is whyindex.pyreadsevent["httpMethod"]and returns{"statusCode": 200, ...}.
add_resource() and add_method():
These two methods define the API structure. add_resource("items") creates the /items path. add_resource("{id}") on top of that creates /items/{id} where {id} is a dynamic path parameter captured and passed to Lambda as event["pathParameters"]["id"]. add_method("GET") attaches an HTTP method to a resource.
CORS configuration:
The default_cors_preflight_options block is what handles Cross-Origin Resource Sharing. Before a browser sends a POST, PUT, or DELETE request to an API on a different domain, it first sends an OPTIONS preflight request to check if the API allows it. This block tells CDK to create an OPTIONS method on every route automatically. For a tutorial, allow_origins=apigw.Cors.ALL_ORIGINS is fine. For production, restrict it to your actual frontend domain like this:
default_cors_preflight_options=apigw.CorsOptions(
allow_origins=["https://my-frontend.example.com", "http://localhost:3000"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)
The Lambda function also returns "Access-Control-Allow-Origin": "*" in every response through api_response(). These two CORS settings work together: the CDK config handles the browser's early OPTIONS preflight request, and the Lambda header handles the CORS policy on the actual API response.
CfnOutput for the API URL:
After deployment, api_endpoints.url resolves to the live API Gateway base URL. CDK prints it in the terminal via CfnOutput so you can copy it directly and start testing.
Deploy the stack with API Gateway
Run cdk diff to see the new API Gateway resources that will be added:
cdk diff
Then deploy:
cdk deploy
After deployment, the terminal will print both outputs:
Outputs:
CdkTutorialCrudApiStack.ApiUrl = https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod/
CdkTutorialCrudApiStack.TableName = CdkTutorialCrudApiStack-ItemsTable...
Copy the ApiUrl. You will need it in the next step.
Step 7: Test the API
Use curl to test every endpoint. Replace YOUR_API_URL with the URL from your deployment output.
Create an item (POST):
# Git Bash / Mac / Linux
curl -X POST https://YOUR_API_URL/prod/items \
-H "Content-Type: application/json" \
-d '{"name": "Chocolate", "description": "The fancy barista edition"}'
# PowerShell
$API_URL = "https://YOUR_API_URL/prod"
Invoke-RestMethod -Uri "$API_URL/items" -Method POST -ContentType 'application/json' -Body '{"name":"Chocolate","description":"The fancy barista edition"}'
The response will include an id. Copy it and use it in the commands below.
Get all items (GET):
curl https://YOUR_API_URL/prod/items
Get a single item (GET):
curl https://YOUR_API_URL/prod/items/PASTE-YOUR-ID-HERE
Update an item (PUT):
curl -X PUT https://YOUR_API_URL/prod/items/PASTE-YOUR-ID-HERE \
-H "Content-Type: application/json" \
-d '{"name": "Dark Chocolate", "description": "Even fancier edition"}'
Delete an item (DELETE):
curl -X DELETE https://YOUR_API_URL/prod/items/PASTE-YOUR-ID-HERE
Step 8: See the CloudFormation Output
Want to see what CDK generates under the hood? Run:
cdk synth
This prints the CloudFormation YAML that CDK produces from your Python code. It is a great way to understand what is actually being sent to AWS, and to appreciate why writing CDK is so much cleaner than writing CloudFormation by hand. One Lambda function in CDK is about 10 lines. The CloudFormation equivalent can be over 100 lines of YAML.
Step 9: Clean Up
When you are done, delete all the resources you created:
cdk destroy
This removes everything from your AWS account in one command. And because all the infrastructure lives in your code, you can bring it all back anytime by running cdk deploy again.
Common Errors and How to Fix Them
"This stack uses assets, so the toolkit stack must be deployed"
You forgot to bootstrap. Run this once per account and region:
cdk bootstrap
"User: arn:aws:iam::... is not authorized"
Your AWS CLI user does not have enough permissions to deploy CDK resources. For a personal learning account, attach AdministratorAccess to your IAM user. Never do this in a shared or production account, scope permissions to only what CDK requires.
"Runtime.ImportModuleError: Unable to import module 'index'"
CDK cannot find your Lambda file. The error means Lambda loaded successfully but could not find index.py to import. Make sure:
- The
lambda/folder exists at the root of your project - The file inside it is named exactly
index.py - The
handler="index.handler"line in your stack matches that filename
Verify with:
ls lambda/
"AccessDeniedException: ... is not authorized to perform: dynamodb:PutItem"
Your Lambda function does not have permission to write to DynamoDB. This means the permissions line is either missing or placed before the table is defined. Make sure this line exists in your stack, after the items_table and api_handler are both defined:
items_table.grant_read_write_data(api_handler)
Wrapping Up
In this article you built a complete serverless CRUD API on AWS using Infrastructure as Code, without clicking through the console once. Here is a summary of every command and what it does:
| Command / Code | What it does |
|---|---|
cdk bootstrap |
Sets up the S3 bucket CDK needs to deploy assets |
cdk diff |
Previews changes before they are applied to AWS |
cdk deploy |
Deploys the stack and provisions all resources |
cdk synth |
Shows the CloudFormation YAML CDK generates |
cdk destroy |
Removes all stack resources from AWS |
dynamodb.Table(...) |
Creates the DynamoDB database |
_lambda.Function(...) |
Creates the Lambda compute layer |
apigw.LambdaRestApi(...) |
Creates the HTTP API endpoints |
grant_read_write_data(...) |
Gives Lambda IAM permission to access DynamoDB |
The biggest advantage of CDK over ClickOps is repeatability. Your infrastructure is versioned, reviewable, and reproducible. Push it to GitHub and anyone on your team (or future you) can deploy the exact same setup with one command.
You can find the full source code here: github.com/Joshua4-0p/Simple-CRUD-API-Using-IaC-and-aws-CDK
Have questions or ran into a different error? Drop it in the comments.

















Top comments (0)