AWS Builder Center
Build a Serverless URL Shortener with AWS SAM, Lambda, and DynamoDB

Build a Serverless URL Shortener with AWS SAM, Lambda, and DynamoDB

A complete, deployable link shortener using API Gateway, Python Lambda functions, and DynamoDB, defined in one SAM template. Covers collision-safe slug generation, atomic click counting, link expiry with TTL, and why 302 beats 301 for tracked redirects.

Every developer builds a URL shortener at some point. It's a small project, but it covers a surprising amount of real backend work: an API, a database, unique ID generation, redirects, expiry and analytics.
In this article we'll build one that runs completely serverless on AWS. It uses **API Gateway (HTTP API)** for routing, **Lambda (Python)** for logic, and **DynamoDB** for storage, all defined in a single **AWS SAM** template. There are no servers to patch, and when nobody is using it, it costs essentially nothing.
**What you'll build:**
- `POST /links` takes a long URL and returns a short link that expires after a set number of days
- `GET /{slug}` redirects to the original URL and counts the click
**What you'll need:**
- An AWS account with the AWS CLI configured (`aws configure`)
- The [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
- Python 3.12
---
## How it fits together
```
Client ──▶ API Gateway (HTTP API) ──┬──▶ CreateLinkFunction ──▶ DynamoDB (PutItem)
└──▶ RedirectFunction ──▶ DynamoDB (UpdateItem)
```
Each route gets its own small Lambda function. DynamoDB stores one item per short link, keyed by its slug.
---
## Step 1: Project layout
```
url-shortener/
├── template.yaml
└── src/
├── create.py
└── redirect.py
```
There's no `requirements.txt`, because the only library we use is `boto3`, and the Lambda Python runtime already includes it.
## Step 2: The SAM template
`template.yaml` defines the table, both functions, their permissions, and the API routes:
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless URL shortener
Globals:
Function:
Runtime: python3.12
Architectures: [arm64]
MemorySize: 256
Timeout: 5
Environment:
Variables:
TABLE_NAME: !Ref LinksTable
Resources:
LinksTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: slug
AttributeType: S
KeySchema:
- AttributeName: slug
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
CreateLinkFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: create.handler
Policies:
- DynamoDBWritePolicy:
TableName: !Ref LinksTable
Events:
Create:
Type: HttpApi
Properties:
Path: /links
Method: POST
RedirectFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: redirect.handler
Policies:
- DynamoDBWritePolicy:
TableName: !Ref LinksTable
Events:
Redirect:
Type: HttpApi
Properties:
Path: /{slug}
Method: GET
Outputs:
ApiUrl:
Description: Base URL of the shortener
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"
```
A few choices worth pointing out:
- **`PAY_PER_REQUEST`** billing means there's no capacity to plan and no charge for an idle table beyond storage.
- **`arm64`** (Graviton) Lambdas are cheaper per millisecond than x86, and pure-Python code runs on them unchanged.
- **`DynamoDBWritePolicy`** is a SAM policy template scoped to this one table. It grants `PutItem` and `UpdateItem`, which is all either function needs.
- **`ServerlessHttpApi`** is the logical ID SAM gives the HTTP API it creates implicitly from the `HttpApi` events, which is why the output can reference it.
---
## Step 3: Creating short links
`src/create.py`:
```python
import base64
import json
import os
import secrets
import string
import time
from urllib.parse import urlparse
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
ALPHABET = string.ascii_letters + string.digits # 62 characters
SLUG_LENGTH = 7 # 62^7 ≈ 3.5 trillion slugs
def respond(status, body):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
def handler(event, context):
raw = event.get("body") or "{}"
if event.get("isBase64Encoded"):
raw = base64.b64decode(raw).decode()
try:
body = json.loads(raw)
except json.JSONDecodeError:
return respond(400, {"error": "Body must be valid JSON"})
url = body.get("url", "")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return respond(400, {"error": "Provide a valid http(s) URL"})
try:
ttl_days = int(body.get("ttl_days", 30))
except (TypeError, ValueError):
return respond(400, {"error": "ttl_days must be a number"})
if not 1 <= ttl_days <= 365:
return respond(400, {"error": "ttl_days must be between 1 and 365"})
now = int(time.time())
expires_at = now + ttl_days * 86400
for _ in range(5):
slug = "".join(secrets.choice(ALPHABET) for _ in range(SLUG_LENGTH))
try:
table.put_item(
Item={
"slug": slug,
"target_url": url,
"clicks": 0,
"created_at": now,
"expires_at": expires_at,
},
ConditionExpression="attribute_not_exists(slug)",
)
break
except table.meta.client.exceptions.ConditionalCheckFailedException:
continue # slug already taken, try another
else:
return respond(503, {"error": "Could not allocate a slug, please retry"})
domain = event["requestContext"]["domainName"]
return respond(201, {
"short_url": f"https://{domain}/{slug}",
"slug": slug,
"expires_at": expires_at,
})
```
The important line is `ConditionExpression="attribute_not_exists(slug)"`. Random slugs *can* collide, and without the condition a collision would silently overwrite someone else's link. With it, DynamoDB rejects the write and we retry with a new slug. There's no "check, then write" race, because the check and the write happen in a single atomic operation.
## Step 4: Redirecting (and counting clicks)
`src/redirect.py`:
```python
import os
import time
import boto3
from botocore.exceptions import ClientError
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def handler(event, context):
slug = event["pathParameters"]["slug"]
try:
result = table.update_item(
Key={"slug": slug},
UpdateExpression="ADD clicks :one",
ConditionExpression="attribute_exists(slug) AND expires_at > :now",
ExpressionAttributeValues={":one": 1, ":now": int(time.time())},
ReturnValues="ALL_NEW",
)
except ClientError as err:
if err.response["Error"]["Code"] == "ConditionalCheckFailedException":
return {
"statusCode": 404,
"headers": {"Content-Type": "text/plain"},
"body": "Link not found or expired",
}
raise
return {
"statusCode": 302,
"headers": {
"Location": result["Attributes"]["target_url"],
"Cache-Control": "no-store",
},
}
```
This one `update_item` call does three jobs at once:
1. **Looks up** the link (`ReturnValues="ALL_NEW"` hands back the item).
2. **Checks** that it exists and hasn't expired.
3. **Increments** the click counter atomically, so concurrent clicks are never lost.
That's one round trip to DynamoDB per redirect, instead of a `get_item` followed by an `update_item`.
**Why 302 and not 301?** A 301 is a *permanent* redirect, and browsers cache it. After the first visit, the browser skips your API entirely, so repeat clicks are never counted and expired links keep working for anyone who has them cached. A 302 with `Cache-Control: no-store` makes every click go through the function.
---
## Step 5: Build and deploy
From the project folder:
```bash
sam build
sam deploy --guided
```
The guided deploy asks a few questions. Give the stack a name like `url-shortener`, pick your region, and accept the defaults for the rest. SAM will warn that **CreateLinkFunction has no authentication** and ask whether that's okay. Answer `y` for this walkthrough (see "Where to go next" for locking it down).
When it finishes, copy the `ApiUrl` value from the **Outputs** section.
## Step 6: Try it
```bash
API=https://abc123xyz.execute-api.ap-south-1.amazonaws.com
curl -s -X POST "$API/links" \
-H "Content-Type: application/json" \
-d '{"url": "https://builder.aws.com", "ttl_days": 7}'
```
You should get back something like:
```json
{"short_url": "https://abc123xyz.execute-api.ap-south-1.amazonaws.com/Kq7Zp2x", "slug": "Kq7Zp2x", "expires_at": 1761500000}
```
Now follow it:
```bash
curl -i "$API/Kq7Zp2x"
```
You'll see `HTTP/2 302` and a `location:` header pointing at the original URL. Open the short link in a browser a few times, then check the item in the DynamoDB console. The `clicks` value goes up with each visit.
---
## A note on TTL
DynamoDB's **Time to Live** deletes expired items for free, but it runs in the background. AWS documents that deletion typically happens within a few days of expiry, not at the exact second. That's why the redirect function checks `expires_at > :now` itself rather than trusting the item to be gone. TTL handles cleanup, and the condition enforces expiry.
---
## Troubleshooting checklist
| Symptom | Likely cause |
|---|---|
| `{"message":"Not Found"}` | The route doesn't match. Check the path and HTTP method (e.g. `GET` to `/links` instead of `POST`) |
| `{"message":"Internal Server Error"}` | The function threw an exception. Run `sam logs -n RedirectFunction --stack-name url-shortener --tail` |
| `AccessDeniedException` in the logs | The function's policy doesn't cover the table, or `TABLE_NAME` points at the wrong table |
| Clicks don't increase on repeat visits | The redirect was cached. Make sure it's a `302` with `Cache-Control: no-store`, and clear the browser cache |
| Expired items still visible in the table | Expected. TTL deletion is asynchronous; the function already treats them as expired |
---
## Where to go next
- **Protect link creation:** add a JWT authorizer (for example, Amazon Cognito) to the `POST /links` route so only signed-in users can create links, while redirects stay public.
- **Custom domain:** map something like `go.yourdomain.com` to the API with an API Gateway custom domain and an ACM certificate.
- **Stats endpoint:** add `GET /links/{slug}/stats` that returns `clicks`, `created_at` and `expires_at`.
- **Rate limiting:** set throttling limits on the API stage so one client can't flood your table.
When you're done experimenting, remove everything with:
```bash
sam delete --stack-name url-shortener
```
The whole service is about 120 lines of Python and YAML, and it scales from zero to a high request rate without any changes. The same pattern (HTTP API → small Lambda → conditional DynamoDB writes) works for many small backends, so it's worth keeping in your toolkit.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article