Build a Serverless URL Shortener

Design a DynamoDB-backed URL shortener with atomic counters and a GSI.

Introduction

30 Second Summary

Every time you click a short link, something has to look up where that link actually goes. That lookup needs to happen in milliseconds, even if millions of people click at the same time.

In this project, you will build a URL shortener powered by Amazon DynamoDB. You will create short links, resolve them back to full URLs, track click counts, and query your links by date.

What You'll Build

A fully functional URL shortener backed by DynamoDB, with Python scripts that create, resolve, track, and query short links.

By the end of this project, you'll have:

  • A DynamoDB table designed around your URL shortener's access patterns instead of normalized SQL entities.
  • A Python script that creates short links, resolves them, and tracks clicks with atomic counters.
  • A Global Secondary Index that enables querying all your links sorted by creation date.
  • Secret Mission: Add TTL (Time to Live) so your links automatically expire after 7 days.

Are there any prerequisites?

You need an AWS account with IAM credentials configured (an access key and secret key). No prior DynamoDB or NoSQL experience is required.

Before We Start

Before We Start...

Install Python, AWS CLI, and boto3

Before you can talk to DynamoDB, you need three tools installed on your machine.

Python is the programming language you will write your URL shortener in. The AWS CLI lets you manage AWS resources from your terminal. And boto3 is the Python library that connects your code to AWS services like DynamoDB.

In this step, get ready to:
  • Install Python and verify it works.
  • Install the AWS CLI and configure your credentials.
  • Install boto3 so Python can communicate with DynamoDB.
Install Python

Python is the language your URL shortener script will be written in. You will install it via the Microsoft Store.

  • Open the Microsoft Store on your computer.
  • Search for Python and install the one published by the Python Software Foundation.
  • After installation completes, open a new Command Prompt.
  • Verify Python is installed by running this command:
python --version

✔️ I see a version number

You should see something like Python 3.14.4. The exact patch number might differ slightly. Python is ready to go.

ⓧ Command not found

If you see an error, make sure you opened a new Command Prompt window after installing Python. The old window does not pick up newly installed programs.

You can also install Python via the command line by running:

winget install 9NQ7512CXL7T

After the install finishes, close and reopen your Command Prompt, then try python --version again.

Still not working?

help me install Python on Windows

Install and configure the AWS CLI

The AWS CLI is a command-line tool that lets you interact with AWS services directly from your terminal. You will also use it to store your AWS credentials so boto3 can authenticate automatically.

  • Download the AWS CLI installer from this link: https://awscli.amazonaws.com/AWSCLIV2.msi.
  • Run the downloaded AWSCLIV2.msi file and follow the installer prompts to completion.
  • After installation completes, open a new Command Prompt window.
  • Verify the AWS CLI is installed by running this command:
aws --version

✔️ I see a version number

You should see output starting with aws-cli/2. followed by a version number. The AWS CLI is ready.

ⓧ Command not found

Make sure you opened a brand new Command Prompt window after the installer finished. The old window does not know about newly installed programs.

If that does not help, try restarting your computer and running aws --version again in a fresh Command Prompt.

Still not working?

help me install the AWS CLI on Windows

Now you need to store your AWS credentials so the CLI (and boto3) can authenticate with your AWS account.

  • Configure your credentials by running this command:
aws configure

The command will prompt you for four values, one at a time. Enter each and press Enter:

  • AWS Access Key ID: paste your IAM access key.
  • AWS Secret Access Key: paste your IAM secret key.
  • Default region name: enter us-east-1.
  • Default output format: enter json.

What does aws configure do?

This command saves your credentials to a file on your computer so that every AWS tool (the CLI, boto3, and other SDKs) can authenticate without you pasting keys into your code.

The credentials are stored in a file at %UserProfile%\.aws\credentials. Never share this file or commit it to version control.

Not sure where to find your access keys?

Your IAM access key and secret key are created in the AWS Console under IAM > Users > your user > Security credentials > Create access key. If you already have keys saved from a previous project, use those.

help me find my AWS access keys

Install boto3

boto3 is the official AWS SDK for Python. It translates your Python function calls into API requests that DynamoDB understands.

  • Install boto3 by running this command:
pip install boto3==1.43.5

Why pin the version?

Pinning to 1.43.5 ensures your output matches this project exactly. boto3 releases frequently, and a newer version could introduce subtle differences.

  • Verify boto3 installed correctly by running this command:
python -c "import boto3; print(boto3.__version__)"

You should see 1.43.5 printed in your terminal.

Seeing a ModuleNotFoundError?

If Python says it cannot find boto3, make sure you are using the same pip that matches your python command. Try running python -m pip install boto3==1.43.5 instead.

help me fix my boto3 installation

Your environment is ready. Next up, you will create your first DynamoDB table and design its key schema around the URL shortener's primary access pattern.

Create the DynamoDB Table

Your tools are installed and your AWS credentials are configured. Now it's time to create the database that will power your URL shortener.

In DynamoDB, you design your table around how you plan to access your data. Your URL shortener's primary access pattern is "given a short code, find the original URL." This makes the short code the perfect partition key for your table.

In this step, get ready to:
  • Write a Python script that defines your DynamoDB table schema.
  • Run the script to create the table in AWS.
  • Verify the table is active and ready to use.
Write the table creation script

You need a project folder to keep your scripts organized. You also need a Python script that tells DynamoDB exactly how to structure your table.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener-project
cd url-shortener-project
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output from ls since the folder is new).

  • Create a new file called create_table.py inside your url-shortener-project folder. Open it in any text editor (Notepad, VS Code, or your preferred editor).
  • Copy the following code into the file:
import boto3

# Connect to DynamoDB in us-east-1
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')

# Create the table with a partition key and a Global Secondary Index
table = dynamodb.create_table(
    TableName='url-shortener',
    KeySchema=[
        {'AttributeName': 'short_code', 'KeyType': 'HASH'}
    ],
    AttributeDefinitions=[
        {'AttributeName': 'short_code', 'AttributeType': 'S'},
        {'AttributeName': 'gsi_pk', 'AttributeType': 'S'},
        {'AttributeName': 'created_at', 'AttributeType': 'S'}
    ],
    GlobalSecondaryIndexes=[
        {
            'IndexName': 'created-date-index',
            'KeySchema': [
                {'AttributeName': 'gsi_pk', 'KeyType': 'HASH'},
                {'AttributeName': 'created_at', 'KeyType': 'RANGE'}
            ],
            'Projection': {'ProjectionType': 'ALL'}
        }
    ],
    BillingMode='PAY_PER_REQUEST'
)

# Wait for AWS to finish creating the table
print("Creating table, please wait...")
table.wait_until_exists()
print(f"Table status: {table.table_status}")
print("Table created successfully!")

What does this code do?

boto3.resource('dynamodb') connects to DynamoDB using the credentials you configured in Step 1. The resource interface lets you pass native Python types (strings, numbers) directly without wrapping them in DynamoDB's type descriptors.

KeySchema defines your table's partition key (short_code). Every item you store must have this key, and DynamoDB uses it to locate items instantly. BillingMode='PAY_PER_REQUEST' means you pay per request instead of pre-provisioning capacity. Perfect for learning since you only pay for what you use.

The GlobalSecondaryIndexes section creates a Global Secondary Index (GSI) called created-date-index. This enables a completely different access pattern (querying links by creation date) without changing how your base table works.

table.wait_until_exists() pauses the script until AWS finishes setting up the table. Without it, you might try to use the table before it is ready.

  • Save create_table.py.

Why on-demand instead of provisioned capacity?

DynamoDB offers two billing modes. Provisioned mode requires you to estimate how many reads and writes per second your table needs. On-demand mode handles that automatically.

For a learning project with a handful of test items, on-demand is simpler and costs fractions of a cent. No capacity planning needed.

Run the script and verify the table

With your script saved, you can now tell Python to execute it. This sends the table creation request to AWS and waits for it to finish.

  • Run your table creation script by entering this command:
python create_table.py

You should see output like this:

Creating table, please wait...
Table status: ACTIVE
Table created successfully!

What does ACTIVE mean?

When DynamoDB finishes creating your table and its GSI, the status changes from CREATING to ACTIVE. This means the table is ready to accept reads and writes.

  • Verify the table exists in AWS by running this command:
aws dynamodb describe-table --table-name url-shortener

You should see a JSON response showing your table details, including "TableStatus": "ACTIVE" and a GlobalSecondaryIndexes section listing created-date-index.

Seeing an error when running the script?

  • ResourceInUseException means a table named url-shortener already exists. Delete it first with aws dynamodb delete-table --table-name url-shortener and run the script again.
  • NoCredentialsError means boto3 cannot find your AWS credentials. Run aws configure again to re-enter your Access Key ID and Secret Access Key.
  • InvalidClientTokenId means your Access Key ID is incorrect or has been deactivated. Check your IAM credentials in the AWS console.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the table is showing ACTIVE status.

ⓧ I'd like to double check the full code

Here is the complete create_table.py file. Make sure your version matches exactly:

import boto3

dynamodb = boto3.resource('dynamodb', region_name='us-east-1')

table = dynamodb.create_table(
    TableName='url-shortener',
    KeySchema=[
        {'AttributeName': 'short_code', 'KeyType': 'HASH'}
    ],
    AttributeDefinitions=[
        {'AttributeName': 'short_code', 'AttributeType': 'S'},
        {'AttributeName': 'gsi_pk', 'AttributeType': 'S'},
        {'AttributeName': 'created_at', 'AttributeType': 'S'}
    ],
    GlobalSecondaryIndexes=[
        {
            'IndexName': 'created-date-index',
            'KeySchema': [
                {'AttributeName': 'gsi_pk', 'KeyType': 'HASH'},
                {'AttributeName': 'created_at', 'KeyType': 'RANGE'}
            ],
            'Projection': {'ProjectionType': 'ALL'}
        }
    ],
    BillingMode='PAY_PER_REQUEST'
)

print("Creating table, please wait...")
table.wait_until_exists()
print(f"Table status: {table.table_status}")
print("Table created successfully!")

Your DynamoDB table is live and ready to store data. Next up, you'll write the functions that create short links and resolve them back to their original URLs.

Store and Retrieve Short Links

Your DynamoDB table is live and waiting for data. Now you need a way to actually write items to it and read them back.

In this step, you will build the core logic of your URL shortener. You will write a Python script that generates random short codes, stores them alongside original URLs in DynamoDB, and retrieves the original URL when given a short code.

In this step, get ready to:
  • Write a create_short_link function that generates a short code and stores it in DynamoDB.
  • Write a resolve_short_link function that retrieves the original URL from a short code.
  • Test both functions by creating and resolving several short links.
Set up the url_shortener.py file

Your URL shortener needs a helper function that generates random short codes. Each code will be 6 characters long, made up of uppercase letters, lowercase letters, and digits.

  • Create a new file called url_shortener.py in the same folder as your create_table.py file.
  • Add the following imports, DynamoDB connection, and helper function to url_shortener.py:
import boto3
import string
import random
from datetime import datetime, timezone
from boto3.dynamodb.conditions import Key

# Connect to the DynamoDB table
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('url-shortener')


def generate_short_code(length=6):
    # Use letters and digits to create a random short code
    characters = string.ascii_letters + string.digits
    return ''.join(random.choices(characters, k=length))

What does this code do?

The first few lines connect to your existing url-shortener table using the boto3 resource interface. This higher-level interface lets you pass native Python types (strings, numbers) directly without wrapping them in DynamoDB type descriptors.

The generate_short_code function picks 6 random characters from a pool of 62 (26 uppercase + 26 lowercase + 10 digits). That gives over 56 billion possible codes, so collisions are extremely unlikely at this scale.

  • Save url_shortener.py.
Write the create_short_link function

This function is the heart of your URL shortener. It generates a short code, then stores it in DynamoDB alongside the original URL, a timestamp, a click counter, and a GSI partition key you will use later for querying.

  • Add the create_short_link function below your generate_short_code function in url_shortener.py:
def create_short_link(original_url):
    short_code = generate_short_code()
    created_at = datetime.now(timezone.utc).isoformat()

    # Store the link in DynamoDB
    table.put_item(
        Item={
            'short_code': short_code,
            'original_url': original_url,
            'created_at': created_at,
            'clicks': 0,
            'gsi_pk': 'LINK'
        }
    )

    print(f"Created short link: {short_code} -> {original_url}")
    return short_code

What does this code do?

The put_item call writes a new item to your DynamoDB table. Each item has five attributes:

  • short_code is the partition key that uniquely identifies this item.
  • original_url stores the full URL the short code points to.
  • created_at records when the link was created as a UTC ISO timestamp.
  • clicks starts at 0 and will be incremented atomically in the next step.
  • gsi_pk is set to 'LINK' for every item. You will use this with the Global Secondary Index later to query all links by date.

Why store gsi_pk now if we use it later?

DynamoDB does not backfill GSI data for items that already exist. If you add items now without gsi_pk, those items would be invisible to the index when you query it in Step 5. Including it from the start means every item you create will be queryable later.

  • Save url_shortener.py.
Add the resolve_short_link function

Creating links is only half the job. You also need to look up the original URL when someone provides a short code. This is the primary access pattern your table was designed for.

  • Add the resolve_short_link function below create_short_link in url_shortener.py:
def resolve_short_link(short_code):
    # Look up the item by its partition key
    response = table.get_item(Key={'short_code': short_code})

    # Handle the case where the short code does not exist
    if 'Item' not in response:
        print(f"Short code '{short_code}' not found.")
        return None

    item = response['Item']
    print(f"Resolved: {short_code} -> {item['original_url']} (clicks: {item['clicks']})")
    return item['original_url']

What does this code do?

The get_item call fetches a single item by its partition key. This is the fastest possible DynamoDB operation because it goes directly to the exact item without scanning.

If the short code does not exist, the response simply will not contain an 'Item' key. DynamoDB does not throw an error for missing items. Your code checks for this and returns None gracefully.

  • Save url_shortener.py.

Seeing a KeyError or AttributeError?

Make sure the Key dictionary in get_item uses exactly 'short_code' as the key name. This must match the partition key you defined when creating the table.

help me debug a KeyError in my DynamoDB get_item call

Test your URL shortener

Time to see your functions in action. You will add a small test block to the bottom of your script, run it, and watch short links get created and resolved in real time.

  • Add the following test code at the bottom of url_shortener.py:
# Quick test
code1 = create_short_link("https://docs.aws.amazon.com/dynamodb/")
code2 = create_short_link("https://www.python.org")

print("\n--- Resolving links ---")
resolve_short_link(code1)
resolve_short_link(code2)
resolve_short_link("nonexistent")

What does this test do?

This creates two short links for real URLs, then resolves all three codes (including one that does not exist). You will see the create confirmations, the successful resolutions, and the graceful "not found" message.

  • Save url_shortener.py.
  • Run the script by running this command:
python url_shortener.py

You should see output showing two links created with random 6-character codes, followed by the resolved URLs and a "not found" message for the nonexistent code.

Seeing a ResourceNotFoundException?

This means the script cannot find the url-shortener table. Confirm the table exists by running:

aws dynamodb describe-table --table-name url-shortener

Also check that region_name='us-east-1' in your script matches the region where you created the table.

help me debug a ResourceNotFoundException in DynamoDB

  • After confirming the output looks correct, remove the test code from the bottom of url_shortener.py (everything below the resolve_short_link function).
  • Save url_shortener.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file with the test code removed.

ⓧ I'd like to double check the full code

Here is the complete url_shortener.py at this point:

import boto3
import string
import random
from datetime import datetime, timezone
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('url-shortener')


def generate_short_code(length=6):
    characters = string.ascii_letters + string.digits
    return ''.join(random.choices(characters, k=length))


def create_short_link(original_url):
    short_code = generate_short_code()
    created_at = datetime.now(timezone.utc).isoformat()

    table.put_item(
        Item={
            'short_code': short_code,
            'original_url': original_url,
            'created_at': created_at,
            'clicks': 0,
            'gsi_pk': 'LINK'
        }
    )

    print(f"Created short link: {short_code} -> {original_url}")
    return short_code


def resolve_short_link(short_code):
    response = table.get_item(Key={'short_code': short_code})

    if 'Item' not in response:
        print(f"Short code '{short_code}' not found.")
        return None

    item = response['Item']
    print(f"Resolved: {short_code} -> {item['original_url']} (clicks: {item['clicks']})")
    return item['original_url']

Your URL shortener can now create and resolve links. Next up, you will add click tracking with atomic counters so every link resolution can safely increment a counter without race conditions.

Track Clicks with Atomic Counters

Your URL shortener can create and resolve links. But a real URL shortener needs to know how many times each link gets clicked.

The tricky part is concurrency. If two people click the same link at the exact same moment, a naive "read the count, add one, write it back" approach could lose a click. DynamoDB solves this with atomic counters. These let you increment a value in a single operation that is guaranteed to never lose an update, no matter how many requests hit simultaneously.

In this step, get ready to:
  • Add a record_click function that atomically increments click counts.
  • Test click tracking by clicking the same link multiple times and verifying the count.
Add the record_click function

DynamoDB's UpdateItem operation can modify an attribute in place without reading it first. You give it an expression like "add 1 to clicks" and DynamoDB handles the rest atomically.

  • Open url_shortener.py in your text editor.
  • Find the resolve_short_link function. Add the following record_click function below it:

def record_click(short_code):
    response = table.update_item(
        Key={'short_code': short_code},
        UpdateExpression='SET clicks = clicks + :inc',
        ExpressionAttributeValues={':inc': 1},
        ReturnValues='UPDATED_NEW'
    )

    new_count = response['Attributes']['clicks']
    print(f"Click recorded for '{short_code}'. Total clicks: {new_count}")
    return new_count

What does this code do?

  • UpdateExpression='SET clicks = clicks + :inc' tells DynamoDB to increment the clicks attribute by the value of :inc. This happens as a single atomic operation on the server.
  • ExpressionAttributeValues={':inc': 1} defines the placeholder :inc as the number 1. You could change this to increment by any amount.
  • ReturnValues='UPDATED_NEW' makes DynamoDB return the new value of the updated attribute, so you immediately know the new click count without a separate read.

Why atomic counters instead of read-then-write?

Without atomic counters, you would need to read the current click count, add 1 in your Python code, then write it back. If two users click at the same time, both read the same count (say 5), both write 6, and you lose a click.

The SET clicks = clicks + :inc expression runs entirely inside DynamoDB. It will never lose an increment, no matter how many concurrent requests hit it.

  • Save url_shortener.py.

Seeing a syntax error?

  • Make sure the function is at the same indentation level as resolve_short_link. It should not be nested inside another function.
  • Check that the UpdateExpression string uses straight single quotes, not curly quotes.
  • help me debug a syntax error in my record_click function
Test click tracking

Time to prove the atomic counter works. You will create a link, click it several times, then resolve it to see the updated count.

  • Open the Python interactive interpreter by running:
python
  • In the Python interpreter, create a test link and click it three times by running these commands one at a time:
from url_shortener import create_short_link, record_click, resolve_short_link

# Create a test link
code = create_short_link('https://example.com/click-test')

# Click it three times
record_click(code)
record_click(code)
record_click(code)

# Resolve to see the updated click count
resolve_short_link(code)
  • Exit the Python interpreter by running:
exit()

You should see the click count increase from 1 to 2 to 3 with each call, then the final resolve should show clicks: 3.

What should I see?

Your output should look similar to this (your short code will be different):

Created short link: aB3xYz -> https://example.com/click-test
Click recorded for 'aB3xYz'. Total clicks: 1
Click recorded for 'aB3xYz'. Total clicks: 2
Click recorded for 'aB3xYz'. Total clicks: 3
Resolved: aB3xYz -> https://example.com/click-test (clicks: 3)

Seeing "clicks" not found or a validation error?

  • If you see ValidationException, make sure you are testing with a link created by your create_short_link function. That function sets clicks: 0 when creating the item. If the attribute does not exist, the update expression fails.
  • If you see ImportError, make sure you saved url_shortener.py before opening the Python interpreter.
  • help me debug my record_click function that is returning an error

✔️ Awesome, I've got everything!

Great. Double check you have saved url_shortener.py with the new record_click function.

ⓧ I'd like to double check the full code

Here is the complete url_shortener.py file at this point in the project:

import boto3
import string
import random
from datetime import datetime, timezone
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('url-shortener')


def generate_short_code(length=6):
    characters = string.ascii_letters + string.digits
    return ''.join(random.choices(characters, k=length))


def create_short_link(original_url):
    short_code = generate_short_code()
    created_at = datetime.now(timezone.utc).isoformat()

    table.put_item(
        Item={
            'short_code': short_code,
            'original_url': original_url,
            'created_at': created_at,
            'clicks': 0,
            'gsi_pk': 'LINK'
        }
    )

    print(f"Created short link: {short_code} -> {original_url}")
    return short_code


def resolve_short_link(short_code):
    response = table.get_item(Key={'short_code': short_code})

    if 'Item' not in response:
        print(f"Short code '{short_code}' not found.")
        return None

    item = response['Item']
    print(f"Resolved: {short_code} -> {item['original_url']} (clicks: {item['clicks']})")
    return item['original_url']


def record_click(short_code):
    response = table.update_item(
        Key={'short_code': short_code},
        UpdateExpression='SET clicks = clicks + :inc',
        ExpressionAttributeValues={':inc': 1},
        ReturnValues='UPDATED_NEW'
    )

    new_count = response['Attributes']['clicks']
    print(f"Click recorded for '{short_code}'. Total clicks: {new_count}")
    return new_count

Your URL shortener now stores links, resolves them, and tracks clicks with zero risk of lost data. Next up, you will add a Global Secondary Index so you can query all your links by creation date.

Query Links by Date with a GSI

In the last step, you added atomic counters to track clicks without race conditions. Your URL shortener can now create, resolve, and count clicks on links.

There is one access pattern missing. Right now, the only way to find a link is if you already know its short code. What if you want to see your most recent links sorted by date? Your table's Global Secondary Index makes this possible by enabling a completely different query pattern without changing your table structure.

In this step, get ready to:
  • Add a list_recent_links function that queries the GSI for links sorted by creation date.
  • Run the full URL shortener demo to see all features working together.
Add the list_recent_links function

Your table already has the GSI created-date-index set up from when you created the table. Every item you store already includes the gsi_pk attribute set to LINK and a created_at timestamp. Now you just need a function that uses this index.

Why Query instead of Scan?

DynamoDB offers two ways to read multiple items: Query and Scan. A Scan reads every single item in the table and filters afterwards. A Query uses the key structure to jump directly to the items you want.

For a table with millions of links, Scan would read them all just to find the 10 most recent. Query uses the GSI's sort key to grab only those 10 items. The difference is the gap between reading 10 items and reading 10 million.

  • In url_shortener.py, add the following function after your record_click function:
def list_recent_links(limit=10):
    # Query the GSI to get links sorted by creation date
    response = table.query(
        IndexName='created-date-index',
        KeyConditionExpression=Key('gsi_pk').eq('LINK'),
        ScanIndexForward=False,
        Limit=limit
    )

    # Print each link with its metadata
    items = response['Items']
    print(f"\nMost recent {len(items)} links:")
    print("-" * 60)
    for item in items:
        print(f"  {item['short_code']} -> {item['original_url']}")
        print(f"    Created: {item['created_at']} | Clicks: {item['clicks']}")
    print("-" * 60)
    return items

What does this code do?

  • IndexName='created-date-index' tells DynamoDB to query the GSI instead of the base table.
  • KeyConditionExpression=Key('gsi_pk').eq('LINK') finds all items that have 'LINK' as their GSI partition key.
  • ScanIndexForward=False reverses the sort order so the most recent timestamps come first.
  • Limit=limit caps the number of results returned, so DynamoDB stops reading after finding enough items.
  • Save url_shortener.py.

Seeing a NameError for Key?

Make sure your imports at the top of the file include from boto3.dynamodb.conditions import Key. This was added in a previous step. If it is missing, add it below your other import statements.

Why is my list_recent_links function not returning any results?

Add the demo block and run the script

Now you will tie everything together with a main block that exercises every function in the script. This creates test links, resolves them, records clicks, and queries by date.

  • At the bottom of url_shortener.py, add this demo block:
if __name__ == '__main__':
    print("=== URL Shortener Demo ===\n")

    # Create three test short links
    code1 = create_short_link("https://docs.aws.amazon.com/dynamodb/")
    code2 = create_short_link("https://www.python.org")
    code3 = create_short_link("https://boto3.amazonaws.com/v1/documentation/api/latest/")

    # Resolve links by short code (including a non-existent one)
    print("\n--- Resolving links ---")
    resolve_short_link(code1)
    resolve_short_link(code2)
    resolve_short_link("nonexistent")

    # Record clicks to test the atomic counter
    print("\n--- Recording clicks ---")
    record_click(code1)
    record_click(code1)
    record_click(code1)
    record_click(code2)

    # Verify click counts were updated
    print("\n--- Checking updated click counts ---")
    resolve_short_link(code1)
    resolve_short_link(code2)

    # Query the GSI for recent links sorted by date
    print("\n--- Listing recent links ---")
    list_recent_links()

What does this demo do?

  • Creates three short links pointing to real documentation URLs.
  • Resolves two valid codes and one invalid code to test the not-found handling.
  • Records multiple clicks on code1 and one click on code2 to test the atomic counter.
  • Calls list_recent_links() to query the GSI and display all links in reverse chronological order.
  • Save url_shortener.py.
  • Run the full demo by running this command:
python url_shortener.py
  • You should see output showing created links, resolved links, recorded clicks, updated click counts, and a table of recent links sorted by date (newest first).

What should I see?

The final section of output should show your links listed with the most recently created one at the top. Each link displays its short code, original URL, creation timestamp, and click count. The link for code1 should show 3 clicks, code2 should show 1 click, and code3 should show 0 clicks.

Seeing a ValidationException?

If the error mentions the index name, double-check you typed created-date-index exactly as it appears (with hyphens, not underscores). The index name must match what was defined in create_table.py.

If you see a ResourceNotFoundException, confirm your table name is url-shortener and your AWS credentials are configured for us-east-1.

Why is my URL shortener script throwing an error when I run it?

✔️ Awesome, I've got everything!

Great. Double check you have saved url_shortener.py and your demo ran successfully.

ⓧ I'd like to double check the full code

Here is the complete url_shortener.py file for comparison:

import boto3
import string
import random
from datetime import datetime, timezone
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('url-shortener')


def generate_short_code(length=6):
    characters = string.ascii_letters + string.digits
    return ''.join(random.choices(characters, k=length))


def create_short_link(original_url):
    short_code = generate_short_code()
    created_at = datetime.now(timezone.utc).isoformat()

    table.put_item(
        Item={
            'short_code': short_code,
            'original_url': original_url,
            'created_at': created_at,
            'clicks': 0,
            'gsi_pk': 'LINK'
        }
    )

    print(f"Created short link: {short_code} -> {original_url}")
    return short_code


def resolve_short_link(short_code):
    response = table.get_item(Key={'short_code': short_code})

    if 'Item' not in response:
        print(f"Short code '{short_code}' not found.")
        return None

    item = response['Item']
    print(f"Resolved: {short_code} -> {item['original_url']} (clicks: {item['clicks']})")
    return item['original_url']


def record_click(short_code):
    response = table.update_item(
        Key={'short_code': short_code},
        UpdateExpression='SET clicks = clicks + :inc',
        ExpressionAttributeValues={':inc': 1},
        ReturnValues='UPDATED_NEW'
    )

    new_count = response['Attributes']['clicks']
    print(f"Click recorded for '{short_code}'. Total clicks: {new_count}")
    return new_count


def list_recent_links(limit=10):
    response = table.query(
        IndexName='created-date-index',
        KeyConditionExpression=Key('gsi_pk').eq('LINK'),
        ScanIndexForward=False,
        Limit=limit
    )

    items = response['Items']
    print(f"\nMost recent {len(items)} links:")
    print("-" * 60)
    for item in items:
        print(f"  {item['short_code']} -> {item['original_url']}")
        print(f"    Created: {item['created_at']} | Clicks: {item['clicks']}")
    print("-" * 60)
    return items


if __name__ == '__main__':
    print("=== URL Shortener Demo ===\n")

    code1 = create_short_link("https://docs.aws.amazon.com/dynamodb/")
    code2 = create_short_link("https://www.python.org")
    code3 = create_short_link("https://boto3.amazonaws.com/v1/documentation/api/latest/")

    print("\n--- Resolving links ---")
    resolve_short_link(code1)
    resolve_short_link(code2)
    resolve_short_link("nonexistent")

    print("\n--- Recording clicks ---")
    record_click(code1)
    record_click(code1)
    record_click(code1)
    record_click(code2)

    print("\n--- Checking updated click counts ---")
    resolve_short_link(code1)
    resolve_short_link(code2)

    print("\n--- Listing recent links ---")
    list_recent_links()

Your URL shortener is fully operational. It creates links, resolves them, tracks clicks atomically, and queries by date using a GSI. Next up, the Secret Mission challenges you to make links automatically expire.

Secret mission

Add TTL for Auto-Expiring Links

What if your short links could clean themselves up? In this secret mission, you'll use DynamoDB's built-in TTL (Time to Live) feature to make links automatically expire after 7 days. No cron jobs, no cleanup scripts. DynamoDB handles the deletion for you.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running or delete them entirely. This project uses DynamoDB with on-demand billing, so costs at this scale are effectively zero.

What about the free tier?

DynamoDB's Always Free tier covers 25 GB of storage, 25 write capacity units, and 25 read capacity units per month for provisioned mode. On-demand mode (which this project uses) charges per request instead. At the scale of a few test items, you would pay fractions of a cent.

Resources you used:

  • A DynamoDB table named url-shortener with on-demand billing.
  • A Global Secondary Index named created-date-index on that table.
  • Local Python scripts (create_table.py and url_shortener.py).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your URL shortener table.

  • Your DynamoDB table stays active and available at all times.
  • Storage costs are covered by the Always Free tier as long as your data stays under 25 GB.
  • On-demand read and write requests at this scale cost fractions of a cent per month.

Pause - I'll come back to this later

DynamoDB tables are always available. There is no way to pause a table like you can with an EC2 instance or RDS database.

  • If you want to reduce any potential cost, delete the table (see the Delete tab).
  • If you want to keep the table for later, choose the Keep tab. Costs are negligible at this scale.

Delete - I don't want to use this again

Remove your DynamoDB table and all its data permanently. This cannot be undone.

  • Delete your DynamoDB table by running this command in your terminal:
aws dynamodb delete-table --table-name url-shortener
  • This removes the table, all items, and the Global Secondary Index in one command.
  • Delete your local Python scripts (create_table.py and url_shortener.py) if you no longer need them.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional URL shortener powered by DynamoDB, complete with atomic click tracking and date-based queries.

You've learned how to:

  • Designed a DynamoDB table around your access patterns using on-demand billing and a partition key schema.
  • Built Python functions that use PutItem, GetItem, and UpdateItem with atomic counters for race-condition-free click tracking.
  • Added a Global Secondary Index to enable a completely different query pattern (by date) without restructuring your table.
  • Added TTL for automatic link expiration so your short links clean themselves up after 7 days.

Ready to quiz yourself?