Build a URL Shortener API with Django
Build a URL shortener REST API with Redis caching to ace system design interviews.
Introduction
30 Second Summary
Every time you click a shortened link on social media, a system instantly looks up the original URL and redirects you. This lookup happens billions of times a day across the internet. It is also the single most-asked system design interview question for mid-level engineers.
In this project, you will build a URL shortener REST API from scratch using Django and Redis. You will implement the exact caching pattern interviewers expect you to explain, so you can whiteboard it from personal experience.
What You'll Build
A URL shortener REST API with three endpoints: one to create short URLs, one to redirect short codes using a Redis read-through cache, and one to view click statistics.
By the end of this project, you'll have:
- A working REST API built with Django REST Framework that creates short codes and redirects users to original URLs.
- A Redis caching layer implementing the read-through cache pattern that eliminates database hits for popular URLs.
- Base62 short code generation with collision handling and click tracking for analytics.
- Secret Mission: Build a sliding window rate limiter using Redis sorted sets.
Are there any prerequisites?
You should have basic Python and Django knowledge (models, views, URLs). You will also need Docker Desktop installed to run Redis locally.
Set Up the Project
Before you can build the URL shortener itself, you need a working local environment with the right tools connected.
In this step, you will create a Django project, install the packages it depends on, and spin up a Redis container that the caching layer will talk to later.
In this step, get ready to:
- Create a Django project with a virtual environment and install Django, DRF, and redis-py.
- Start a Redis container with Docker and verify the connection.
- Configure Django settings for DRF and Redis.
Create a Django project with a virtual environment
A virtual environment isolates this project's packages from the rest of your system. You will create one, activate it, then install everything the project needs.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
- 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
cd url-shortener
- Confirm the folder exists by running:
ls
The output will be empty since the folder is new. That is expected.
- Create a virtual environment by running this command:
python3 -m venv .venv
- Activate the virtual environment:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\Activate.ps1
You should see (.venv) appear at the start of your terminal prompt.
Don't see (.venv) in your prompt?
On Windows, PowerShell may block scripts by default. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned and try the activation command again.
On macOS, double-check you typed source before the path. Without it, the shell does not execute the activation script.
help me activate my Python virtual environment
- Install Django, Django REST Framework, and redis-py by running:
pip install django djangorestframework redis
What are these packages?
- django is the web framework that handles routing, models, and the ORM.
- djangorestframework adds REST API tools like serializers, request parsing, and the browsable API.
- redis (redis-py) is the Python client that talks to a Redis server.
You should see output ending with Successfully installed followed by a list of packages.
Installation failed?
Make sure (.venv) is visible in your prompt before running pip install. If it is missing, your virtual environment is not active.
help me fix a pip install error for django djangorestframework redis
- Create the Django project structure by running:
django-admin startproject urlshortener .
What does the dot at the end do?
The . tells Django to create the project files in the current directory instead of nesting them inside another folder. This keeps your folder structure flat and easier to navigate.
- Create the app that will hold your URL shortener logic by running:
python3 manage.py startapp shortener
- Confirm the project structure was created by running:
ls
You should see manage.py, a urlshortener/ folder, and a shortener/ folder in the output.
Start a Redis container and verify the connection
Redis is the in-memory data store your caching layer will use. You will run it inside a Docker container so there is nothing extra to install on your machine.
- Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter.
- Start a Redis container by running this command in your terminal:
docker run -d --name redis-shortener -p 6379:6379 redis:latest
What does this command do?
- -d runs the container in the background so your terminal stays free.
- --name redis-shortener gives the container a recognizable name.
- -p 6379:6379 maps the container's Redis port to your machine's port 6379.
- redis:latest pulls the latest official Redis image from Docker Hub.
You should see a long hash string printed to the terminal. This is the container ID confirming Redis is running.
- Verify that Redis is responding by running:
docker exec -it redis-shortener redis-cli PING
You should see PONG printed to the terminal. This confirms Redis is running and accepting connections.
Not seeing PONG?
If Docker says the container is not running, check that Docker Desktop is open and fully started. The Docker whale icon in your menu bar or system tray should be steady (not animating).
If you see a port conflict error, another process is using port 6379. Stop that process or change the left side of -p to a different port (e.g. -p 6380:6379).
help me get my Redis Docker container running
Configure Django settings for DRF and Redis
Django needs to know about the packages you installed. You will register them in the project settings file and add the Redis connection details.
- Open the project in VS Code by running:
code .
Command not found?
If code is not recognized, open VS Code manually, then press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows). Type shell command and select Shell Command: Install 'code' command in PATH. Then restart your terminal and retry code ..
help me open my project in VS Code from the terminal
- In VS Code, open urlshortener/settings.py from the file explorer sidebar.
- Find the INSTALLED_APPS list. Replace the entire list with the following so that rest_framework and shortener are registered:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'shortener',
]
- Scroll to the very bottom of settings.py and add the Redis connection configuration:
# Redis Configuration
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
What do these settings do?
INSTALLED_APPS tells Django which packages to load when the server starts. Without rest_framework here, DRF's serializers and views would not work. Without shortener, Django would not find your models or migrations.
REDIS_HOST and REDIS_PORT are custom settings your code will import later to connect to the Redis container you just started.
- Save urlshortener/settings.py.
- Verify Django can load the new settings by running this in your terminal:
python3 manage.py check
You should see System check identified no issues (0 silenced). printed to the terminal.
Seeing a ModuleNotFoundError?
If the error mentions rest_framework or redis, your virtual environment may not be active. Check that (.venv) appears in your prompt. If not, re-activate it and try again.
If the error mentions shortener, make sure you ran python3 manage.py startapp shortener earlier and that the shortener/ folder exists at the same level as manage.py.
help me fix my Django settings configuration
✔️ Awesome, I've got everything!
Great. Double check you have saved urlshortener/settings.py and your terminal shows no issues.
ⓧ I'd like to double check the full code
Here is what the additions to urlshortener/settings.py should look like:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'shortener',
]
# Redis Configuration
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
Your environment is ready. You have Django, DRF, and redis-py installed, a Redis container running in the background, and your project settings configured. Next up, you will design the data model that stores shortened URLs.
Design the Data Model
In a system design interview, the first thing you'd do after clarifying requirements is sketch out the data model. The schema you choose determines what queries are fast, what indexes you need, and how the system performs under load.
In this step, you'll think through the URL shortener's data requirements the same way you would on a whiteboard. Then you'll translate those decisions into a Django model and verify it creates the right database table.
In this step, get ready to:
- Think through the data requirements like you would in an interview.
- Create the ShortenedURL model with the right fields and indexes.
- Run migrations and verify the database table exists.
Think through the requirements
Before writing any code, pause and ask yourself the three questions an interviewer expects you to answer about any data store.
- What data do we need to store?
- What queries do we need to run?
- What is the read/write ratio?
Why does read/write ratio matter?
Knowing that reads dominate tells you where to invest optimization effort. For a URL shortener, you want the lookup-by-short-code path to be as fast as possible.
This is exactly why you'll add a Redis cache in front of the database in a later step. The database index you'll set up now handles the baseline. The cache handles the scale.
Create the ShortenedURL model
Now translate those design decisions into a Django model. Each field maps directly to a requirement you just identified.
- Open shortener/models.py in VS Code and replace its contents with the following code:
from django.db import models
class ShortenedURL(models.Model):
original_url = models.URLField(max_length=2048)
short_code = models.CharField(max_length=10, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
clicks = models.IntegerField(default=0)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.short_code} -> {self.original_url}"
What does this code do?
Each field directly supports the queries and data requirements you identified:
- original_url stores URLs up to 2048 characters (the practical max length for most browsers).
- short_code is marked unique=True so no two URLs share a code, and db_index=True so the database can find a code in O(log n) time instead of scanning every row.
- clicks tracks how many times a short URL has been visited. This powers the analytics endpoint you'll build later.
- ordering = ['-created_at'] returns newest URLs first when you query the table.
- Save shortener/models.py.
Why unique plus db_index?
In Django, unique=True already creates a database index under the hood. Adding db_index=True explicitly makes the intent clear to anyone reading the code. In an interview, explicitly stating your indexing strategy shows you're thinking about query performance.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from django.db import models
class ShortenedURL(models.Model):
original_url = models.URLField(max_length=2048)
short_code = models.CharField(max_length=10, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
clicks = models.IntegerField(default=0)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.short_code} -> {self.original_url}"
Run migrations and verify the table
Django uses migrations to translate your model into actual database tables. You need two commands: one to generate the migration file, and one to apply it.
- Generate the migration file by running this command in your terminal:
python manage.py makemigrations
- You should see output mentioning shortener/migrations/0001_initial.py and the creation of the ShortenedURL model.
- Apply the migration to create the table in your database by running:
python manage.py migrate
- You should see a list of migrations being applied. Look for a line containing shortener with OK next to it.
Seeing 'No changes detected'?
This means Django cannot find your model. Check that 'shortener' is listed in INSTALLED_APPS in your urlshortener/settings.py file. Also verify that your model code is saved in shortener/models.py (not in the wrong directory).
Help me debug why makemigrations shows no changes detected
To confirm the table was created, open the Django shell and check the database:
- Verify the table exists by running:
python manage.py shell -c "from shortener.models import ShortenedURL; print(ShortenedURL.objects.count())"
- You should see 0 printed. This confirms the table exists and is empty, ready for data.
Seeing 'no such table' error?
This means the migrate command did not run successfully. Run python manage.py migrate again and check for any error output. Make sure you're running the command from the same folder that contains manage.py.
Help me fix my Django migration issue
Your data model is locked in. You now have a database table optimized for the exact query pattern a URL shortener needs: fast lookups by short code. Next up, you'll build the endpoint that generates short codes and writes to this table.
Build the Shortening Endpoint
Your data model is ready to store shortened URLs. Now it's time to build the API endpoint that accepts a long URL and returns a short code.
This step covers the full write path: generating a unique Base62 short code, saving it to the database, and caching the mapping in Redis so future lookups are instant. You'll also handle the edge case where two generated codes collide.
In this step, get ready to:
- Create a Base62 short code generator.
- Build the POST /api/shorten/ endpoint with collision handling and Redis caching.
- Wire up URL routing and test the endpoint.
Create the Base62 short code generator
Base62 encoding uses all alphanumeric characters (a-z, A-Z, 0-9) to produce URL-safe codes. A 7-character Base62 code gives over 3.5 trillion possible combinations, making collisions extremely unlikely.
- Create a new file called utils.py inside your shortener folder, then add this code:
import string
import random
BASE62_CHARS = string.ascii_letters + string.digits
def generate_short_code(length=7):
"""Generate a random Base62 short code."""
return ''.join(random.choices(BASE62_CHARS, k=length))
What does this code do?
BASE62_CHARS combines all lowercase letters, uppercase letters, and digits into a 62-character alphabet. Every character is safe to use in a URL without encoding.
generate_short_code() picks 7 random characters from that alphabet using random.choices(). The result is a string like aB3xK9m that maps to a single original URL.
- Save shortener/utils.py.
Why random Base62 instead of a counter?
A counter (1, 2, 3...) is predictable. Anyone can guess your next short code. Random Base62 codes are unguessable, which adds a layer of privacy.
The trade-off is that random codes can collide. Two random generations might produce the same string. You'll handle that in the view with retry logic.
✔️ Awesome, I've got everything!
Great. Make sure you've saved shortener/utils.py before moving on.
ⓧ I'd like to double check the full code
import string
import random
BASE62_CHARS = string.ascii_letters + string.digits
def generate_short_code(length=7):
"""Generate a random Base62 short code."""
return ''.join(random.choices(BASE62_CHARS, k=length))
Build the POST endpoint with collision handling
The shorten endpoint needs two supporting pieces: a serializer to validate incoming URLs, and a serializer to format the response. Django REST Framework serializers handle both input validation and output formatting.
- Create a new file called serializers.py inside your shortener folder, then add this code:
from rest_framework import serializers
from .models import ShortenedURL
class URLCreateSerializer(serializers.Serializer):
original_url = serializers.URLField(max_length=2048)
class URLDetailSerializer(serializers.ModelSerializer):
short_url = serializers.SerializerMethodField()
class Meta:
model = ShortenedURL
fields = ['id', 'original_url', 'short_code', 'short_url', 'clicks', 'created_at']
def get_short_url(self, obj):
request = self.context.get('request')
if request:
return request.build_absolute_uri(f'/{obj.short_code}')
return f'http://localhost:8000/{obj.short_code}'
What does this code do?
URLCreateSerializer validates the incoming POST data. It ensures original_url is a valid URL and no longer than 2048 characters. DRF rejects malformed URLs automatically.
URLDetailSerializer formats the response. The get_short_url method builds the full redirect URL (like http://localhost:8000/aB3xK9m) so the caller gets a clickable link back.
- Save shortener/serializers.py.
Seeing a red underline on the import?
If VS Code shows an error on from .models import ShortenedURL, your Python interpreter might not be set to the virtual environment. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and pick the one inside your .venv folder.
help me fix my Python interpreter in VS Code
✔️ Awesome, I've got everything!
Great. Make sure you've saved shortener/serializers.py before moving on.
ⓧ I'd like to double check the full code
from rest_framework import serializers
from .models import ShortenedURL
class URLCreateSerializer(serializers.Serializer):
original_url = serializers.URLField(max_length=2048)
class URLDetailSerializer(serializers.ModelSerializer):
short_url = serializers.SerializerMethodField()
class Meta:
model = ShortenedURL
fields = ['id', 'original_url', 'short_code', 'short_url', 'clicks', 'created_at']
def get_short_url(self, obj):
request = self.context.get('request')
if request:
return request.build_absolute_uri(f'/{obj.short_code}')
return f'http://localhost:8000/{obj.short_code}'
Now for the main event. The view function ties everything together: it validates the URL, generates a short code with collision retry logic, saves to the database, and caches the mapping in Redis.
- Create a new file called views.py inside your shortener folder, then add this code:
import redis
from django.conf import settings
from django.db.models import F
from django.shortcuts import redirect
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import ShortenedURL
from .serializers import URLCreateSerializer, URLDetailSerializer
from .utils import generate_short_code
# Connect to Redis
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=0,
decode_responses=True,
)
CACHE_TTL = 3600 # 1 hour
@api_view(['POST'])
def shorten_url(request):
"""Create a shortened URL."""
serializer = URLCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
original_url = serializer.validated_data['original_url']
# Generate unique short code with collision handling
max_attempts = 5
for attempt in range(max_attempts):
short_code = generate_short_code()
if not ShortenedURL.objects.filter(short_code=short_code).exists():
break
else:
return Response(
{'error': 'Could not generate a unique short code. Please try again.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Save to database
shortened = ShortenedURL.objects.create(
original_url=original_url,
short_code=short_code,
)
# Cache the mapping in Redis
redis_client.set(f'short:{short_code}', original_url, ex=CACHE_TTL)
detail_serializer = URLDetailSerializer(shortened, context={'request': request})
return Response(detail_serializer.data, status=status.HTTP_201_CREATED)
What does this code do?
The redis_client at the top creates a single connection to your Redis container. The decode_responses=True setting means Redis returns strings instead of raw bytes.
The collision-retry loop generates a short code, checks if it already exists in the database, and retries up to 5 times. The for...else construct is a Python pattern where the else block only runs if the loop completed without hitting break. That means all 5 attempts collided.
After saving to the database, the code also writes the mapping to Redis with a 1-hour TTL. This is write-through caching: the cache is populated at write time so the first read is already a cache hit.
- Save shortener/views.py.
Why are there unused imports?
You may notice F and redirect in the imports. These will be used in the next step when you build the redirect endpoint. Including them now saves you from editing the imports later.
Redis connection error when the file loads?
If you see errors about Redis connection, make sure your Docker container is still running. Open a new terminal and run docker ps to check. If nothing shows up, restart the container.
help me fix my Redis Docker container connection
✔️ Awesome, I've got everything!
Great. Make sure you've saved shortener/views.py before moving on.
ⓧ I'd like to double check the full code
import redis
from django.conf import settings
from django.db.models import F
from django.shortcuts import redirect
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import ShortenedURL
from .serializers import URLCreateSerializer, URLDetailSerializer
from .utils import generate_short_code
# Connect to Redis
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=0,
decode_responses=True,
)
CACHE_TTL = 3600 # 1 hour
@api_view(['POST'])
def shorten_url(request):
"""Create a shortened URL."""
serializer = URLCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
original_url = serializer.validated_data['original_url']
# Generate unique short code with collision handling
max_attempts = 5
for attempt in range(max_attempts):
short_code = generate_short_code()
if not ShortenedURL.objects.filter(short_code=short_code).exists():
break
else:
return Response(
{'error': 'Could not generate a unique short code. Please try again.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Save to database
shortened = ShortenedURL.objects.create(
original_url=original_url,
short_code=short_code,
)
# Cache the mapping in Redis
redis_client.set(f'short:{short_code}', original_url, ex=CACHE_TTL)
detail_serializer = URLDetailSerializer(shortened, context={'request': request})
return Response(detail_serializer.data, status=status.HTTP_201_CREATED)
Wire up URL routing and test the endpoint
Django needs to know which URL paths map to which views. You'll create a URL config for the shortener app, then include it in the project's main URL config.
- Create a new file called urls.py inside your shortener folder, then add this code:
from django.urls import path
from . import views
urlpatterns = [
path('api/shorten/', views.shorten_url, name='shorten_url'),
]
What does this code do?
This maps the URL path api/shorten/ to your shorten_url view function. When someone sends a POST request to that path, Django calls your view.
- Save shortener/urls.py.
- Now open urlshortener/urls.py (in the urlshortener project folder, not the shortener app folder) and replace its contents with this:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('shortener.urls')),
]
What does this code do?
The include('shortener.urls') line connects your app's URL patterns to the main project. The empty string '' means all shortener URLs start at the root level, so api/shorten/ is accessible at http://localhost:8000/api/shorten/.
- Save urlshortener/urls.py.
- Start the Django development server by running this command:
python manage.py runserver
You should see output ending with Starting development server at http://127.0.0.1:8000/.
- Open a new terminal tab (keep the server running in the current one) and test the endpoint by running this command:
macOS
curl -X POST http://localhost:8000/api/shorten/ \
-H "Content-Type: application/json" \
-d '{"original_url": "https://www.example.com/very/long/url/that/needs/shortening"}'
Windows
curl.exe -X POST http://localhost:8000/api/shorten/ -H "Content-Type: application/json" -d "{\"original_url\": \"https://www.example.com/very/long/url/that/needs/shortening\"}"
You should see a JSON response with a 201 Created status containing your new short code and the full short URL.
Use the DRF browsable API instead
If curl syntax feels fiddly, open http://localhost:8000/api/shorten/ in your browser. DRF renders a web form where you can paste a URL and click POST. The result is the same.
Seeing a connection error or 500 response?
- If you see a connection refused error, make sure the Django server is running in your other terminal tab.
- If you get a 500 error mentioning Redis, confirm your Docker container is still running with docker ps.
- If you get a 400 error, check that your JSON is valid. The key must be original_url and the value must be a full URL starting with http:// or https://.
help me debug my shorten endpoint error
✔️ Awesome, I've got everything!
Great. Make sure both shortener/urls.py and urlshortener/urls.py are saved.
ⓧ I'd like to double check the full code
from django.urls import path
from . import views
urlpatterns = [
path('api/shorten/', views.shorten_url, name='shorten_url'),
]
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('shortener.urls')),
]
Your shortening endpoint is live. Next up, you'll build the redirect endpoint that turns those short codes back into full URLs using a Redis read-through cache.
Build the Redirect with Read-Through Caching
Your shortening endpoint can create short URLs and store them in both the database and Redis. Now you need the other half of the system: the redirect endpoint that resolves a short code back to the original URL.
This is where the read-through cache pattern comes in. The redirect endpoint checks Redis first for an O(1) lookup. If the key isn't there, it falls back to the database and populates the cache for next time.
In this step, get ready to:
- Implement the redirect endpoint with Redis read-through caching.
- Add a stats endpoint to view click counts.
- Test the full create-redirect-stats flow end to end.
Implement the redirect endpoint with read-through caching
The redirect endpoint is the most performance-critical part of a URL shortener. In production, this endpoint handles 100x more traffic than the creation endpoint because every click on a short link hits it.
What is read-through caching?
A read-through cache sits between your application and the database. When a request comes in, you check the cache first. If the data is there (a cache hit), you return it immediately without touching the database.
If the data isn't in the cache (a cache miss), you query the database, store the result in the cache for future requests, and then return it. Over time, hot URLs stay cached and never hit the database at all.
- In shortener/views.py, find the end of the shorten_url function and add the following redirect_url view below it:
@api_view(['GET'])
def redirect_url(request, short_code):
"""Redirect a short code to the original URL (read-through cache)."""
# Step 1: Check Redis cache
cached_url = redis_client.get(f'short:{short_code}')
if cached_url:
# Cache hit - increment clicks
ShortenedURL.objects.filter(short_code=short_code).update(
clicks=F('clicks') + 1
)
return redirect(cached_url)
# Step 2: Cache miss - check database
try:
url_obj = ShortenedURL.objects.get(short_code=short_code)
except ShortenedURL.DoesNotExist:
return Response(
{'error': 'Short URL not found.'},
status=status.HTTP_404_NOT_FOUND,
)
# Step 3: Write to cache for future requests
redis_client.set(f'short:{short_code}', url_obj.original_url, ex=CACHE_TTL)
# Increment clicks
url_obj.clicks = F('clicks') + 1
url_obj.save(update_fields=['clicks'])
return redirect(url_obj.original_url)
What does this code do?
This function implements the three-step read-through cache pattern that interviewers expect you to explain:
- redis_client.get(f'short:{short_code}') checks Redis for the cached URL. If it exists, Django immediately issues a 302 redirect without querying the database.
- On a cache miss, the code queries the database. If the short code doesn't exist at all, it returns a 404.
- redis_client.set(..., ex=CACHE_TTL) writes the URL to Redis after a miss so subsequent requests hit the cache instead of the database.
- F('clicks') + 1 uses Django's F expression for an atomic database increment. This prevents race conditions when multiple requests arrive simultaneously.
- Save shortener/views.py.
You won't see a change yet. The next substep adds the stats endpoint, and then you'll wire up both URL routes and test everything together.
Seeing a syntax error on save?
- Make sure the new function is at the module level (same indentation as shorten_url), not nested inside another function.
- Check for a blank line between the end of shorten_url and the @api_view decorator for redirect_url.
Help me fix a syntax error in my redirect_url view
Add the stats endpoint
Click tracking is meaningless without a way to view the data. The stats endpoint gives you a simple read-only view of how many times a short URL has been clicked.
- In shortener/views.py, add the following url_stats view below the redirect_url function you just created:
@api_view(['GET'])
def url_stats(request, short_code):
"""Get statistics for a shortened URL."""
try:
url_obj = ShortenedURL.objects.get(short_code=short_code)
except ShortenedURL.DoesNotExist:
return Response(
{'error': 'Short URL not found.'},
status=status.HTTP_404_NOT_FOUND,
)
serializer = URLDetailSerializer(url_obj, context={'request': request})
return Response(serializer.data)
What does this code do?
- This view looks up a ShortenedURL by its short code and returns the full object through URLDetailSerializer. This includes the click count, creation time, and the reconstructed short URL.
- There is no caching here because stats requests are infrequent compared to redirects. In a system design interview, you would explain this as separating your read-heavy path (redirects) from your analytics path (stats).
- Save shortener/views.py.
You won't see a change yet. The next substep wires up the URL routes so Django knows how to reach these new views.
Seeing an indentation error?
- Make sure url_stats is at the same indentation level as redirect_url and shorten_url (not nested inside either).
- Confirm you have two blank lines between the end of redirect_url and the @api_view(['GET']) decorator for url_stats. Python convention uses two blank lines between top-level definitions, but a single blank line will also work.
Help me fix my url_stats view
Wire up URL routes and test the full flow
Both views are written. Now you need to register them in your URL configuration and test the full lifecycle: create a short URL, redirect through it, and check the stats.
- Open shortener/urls.py and replace the entire contents with:
from django.urls import path
from . import views
urlpatterns = [
path('api/shorten/', views.shorten_url, name='shorten_url'),
path('api/stats/<str:short_code>/', views.url_stats, name='url_stats'),
path('<str:short_code>/', views.redirect_url, name='redirect_url'),
]
Why does route order matter here?
Django matches URL patterns from top to bottom. The <str:short_code>/ pattern is a catch-all that would match anything, including api/stats/abc1234/. By placing the more specific api/ routes first, Django checks them before falling through to the generic redirect pattern.
- Save shortener/urls.py.
- Make sure your Django dev server is still running. If it stopped, restart it by running:
python manage.py runserver
Now test the full flow. First, create a new short URL by running this in a separate terminal:
curl -X POST http://localhost:8000/api/shorten/ -H "Content-Type: application/json" -d '{"original_url": "https://www.example.com"}'
You should see a JSON response with a short_code field. Copy that short code value for the next commands.
- Test the redirect by running this command, replacing the short code with yours:
curl -I http://localhost:8000/[[SHORT_CODE="your short code from the previous response"]]/
You should see a 302 Found response with a Location: https://www.example.com header. This confirms the redirect is working.
- Check the click count by running the stats endpoint:
curl http://localhost:8000/api/stats/[[SHORT_CODE="your short code from the previous response"]]/
The response should show "clicks": 1 (or higher if you ran the redirect multiple times). Each visit to the short URL increments the counter.
Getting a 404 on the redirect?
- Double check you copied the short code correctly from the POST response. It should be 7 characters.
- Make sure you included the trailing slash in the URL: http://localhost:8000/abc1234/ not http://localhost:8000/abc1234.
- Verify the route order in shortener/urls.py. The <str:short_code>/ path must be last in the list.
Help me debug my redirect endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full shortener/views.py at this point:
import redis
from django.conf import settings
from django.db.models import F
from django.shortcuts import redirect
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import ShortenedURL
from .serializers import URLCreateSerializer, URLDetailSerializer
from .utils import generate_short_code
# Connect to Redis
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=0,
decode_responses=True,
)
CACHE_TTL = 3600 # 1 hour
@api_view(['POST'])
def shorten_url(request):
"""Create a shortened URL."""
serializer = URLCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
original_url = serializer.validated_data['original_url']
# Generate unique short code with collision handling
max_attempts = 5
for attempt in range(max_attempts):
short_code = generate_short_code()
if not ShortenedURL.objects.filter(short_code=short_code).exists():
break
else:
return Response(
{'error': 'Could not generate a unique short code. Please try again.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Save to database
shortened = ShortenedURL.objects.create(
original_url=original_url,
short_code=short_code,
)
# Cache the mapping in Redis
redis_client.set(f'short:{short_code}', original_url, ex=CACHE_TTL)
detail_serializer = URLDetailSerializer(shortened, context={'request': request})
return Response(detail_serializer.data, status=status.HTTP_201_CREATED)
@api_view(['GET'])
def redirect_url(request, short_code):
"""Redirect a short code to the original URL (read-through cache)."""
# Step 1: Check Redis cache
cached_url = redis_client.get(f'short:{short_code}')
if cached_url:
# Cache hit - increment clicks
ShortenedURL.objects.filter(short_code=short_code).update(
clicks=F('clicks') + 1
)
return redirect(cached_url)
# Step 2: Cache miss - check database
try:
url_obj = ShortenedURL.objects.get(short_code=short_code)
except ShortenedURL.DoesNotExist:
return Response(
{'error': 'Short URL not found.'},
status=status.HTTP_404_NOT_FOUND,
)
# Step 3: Write to cache for future requests
redis_client.set(f'short:{short_code}', url_obj.original_url, ex=CACHE_TTL)
# Increment clicks
url_obj.clicks = F('clicks') + 1
url_obj.save(update_fields=['clicks'])
return redirect(url_obj.original_url)
@api_view(['GET'])
def url_stats(request, short_code):
"""Get statistics for a shortened URL."""
try:
url_obj = ShortenedURL.objects.get(short_code=short_code)
except ShortenedURL.DoesNotExist:
return Response(
{'error': 'Short URL not found.'},
status=status.HTTP_404_NOT_FOUND,
)
serializer = URLDetailSerializer(url_obj, context={'request': request})
return Response(serializer.data)
And here is shortener/urls.py:
from django.urls import path
from . import views
urlpatterns = [
path('api/shorten/', views.shorten_url, name='shorten_url'),
path('api/stats/<str:short_code>/', views.url_stats, name='url_stats'),
path('<str:short_code>/', views.redirect_url, name='redirect_url'),
]
Your URL shortener is fully functional. You can create short URLs, redirect through them, and track clicks. Next up, you'll peek under the hood and observe the cache in action using Redis CLI.
Observe the Cache in Action
Your URL shortener works end-to-end, but the most important part of this project is invisible. The Redis caching layer is silently intercepting requests before they hit your database.
In a system design interview, you need to explain HOW caching improves performance, not just that it does. In this step, you will peek inside Redis to see the cache in action, simulate a cache miss, and discuss how this architecture scales.
In this step, get ready to:
- Inspect Redis keys and TTLs using the Redis CLI.
- Test cache hit and cache miss paths by deleting a key manually.
- Discuss how this architecture scales at 10K, 100K, and 1M requests per second.
Inspect cached keys with Redis CLI
Redis includes a command-line interface that lets you query the data store directly. Since your Redis instance runs inside a Docker container, you will use docker exec to open the CLI inside that container.
- Find the name of your running Redis container by running this command:
docker ps
You should see a table listing your running containers. Look at the NAMES column on the far right for your Redis container name.
- Open the Redis CLI inside your container by running this command:
docker exec -it [[REDIS_CONTAINER="your Redis container name from docker ps"]] redis-cli
Your terminal prompt changes to 127.0.0.1:6379> indicating you are now inside the Redis CLI.
Seeing 'No such container'?
Double-check the container name matches exactly what docker ps showed you. Container names are case-sensitive.
If you don't see any running containers, your Redis container may have stopped. Re-run it with the same Docker run command you used in Step 1.
Help me connect to my Redis container
- List all cached short URL keys by running this inside the Redis CLI:
KEYS short:*
You should see one or more keys like short:aBcDeFg listed. Each key corresponds to a shortened URL you created in the previous step.
- Pick one of the short codes from the list and retrieve its cached value:
GET short:[[SHORT_CODE="one of your short codes from the KEYS output"]]
Redis returns the original URL that this short code maps to. This is the exact value your redirect endpoint retrieves on a cache hit, completely bypassing the database.
- Check how much time is left before this key expires:
TTL short:[[SHORT_CODE="one of your short codes from the KEYS output"]]
You should see a number (in seconds) less than or equal to 3600. This is the remaining time-to-live before Redis automatically evicts this key.
What is TTL?
TTL stands for Time To Live. When you set a key with ex=3600 in your Python code, Redis starts a countdown. Once it reaches zero, the key is deleted automatically.
This prevents stale data from living in the cache forever. In a production system, you would tune this value based on how frequently URLs change versus how much database load you want to absorb.
Test cache miss vs cache hit paths
The real power of a read-through cache is what happens on a miss. When Redis does not have the key, your endpoint falls back to the database and then repopulates the cache for next time.
- First, confirm your short code is currently cached (this is the cache hit path). In a new terminal window, run:
curl -I http://localhost:8000/[[SHORT_CODE="one of your short codes"]]/
You should see a 302 Found response with a Location header pointing to the original URL. Redis served this lookup without touching the database.
- Now simulate a cache miss. Go back to your Redis CLI terminal and delete that key:
DEL short:[[SHORT_CODE="the same short code you just tested"]]
Redis returns (integer) 1 confirming the key was deleted.
- Verify the key is gone:
GET short:[[SHORT_CODE="the same short code"]]
Redis returns (nil) confirming the key no longer exists in the cache.
- Now trigger the cache miss path. In your other terminal, request the same short code again:
curl -I http://localhost:8000/[[SHORT_CODE="one of your short codes"]]/
You still get a 302 Found redirect. The endpoint fell back to the database, found the URL, served the redirect, and repopulated the cache.
- Back in the Redis CLI, confirm the cache was repopulated:
GET short:[[SHORT_CODE="the same short code"]]
The original URL is back in the cache. The read-through pattern filled it in automatically after the miss.
What just happened?
You just observed the complete read-through cache lifecycle. On the first request after deletion, Redis had nothing (cache miss). Your redirect_url view then queried the database, served the redirect, and wrote the result back to Redis.
Every subsequent request for that short code hits Redis directly. The database is only touched once per cache miss. This is the exact flow you would whiteboard in an interview.
Discuss how this architecture scales
In an interview, the interviewer will push you beyond your working prototype. They want to hear you reason about what happens when traffic grows by orders of magnitude.
What happens at 10K requests per second?
Your current single-server setup would struggle at this scale. The bottleneck is the database. However, your Redis cache already eliminates most database reads for popular URLs. At 10K rps with a high cache hit rate, Redis handles the majority of traffic while the database only sees cache misses.
The key insight to share: URL shorteners have a read/write ratio around 100:1. Most requests are redirects (reads), not URL creations (writes). Caching addresses the dominant access pattern.
What happens at 100K requests per second?
A single Redis instance can handle around 100K simple GET operations per second. At this scale, you would need to add Redis replicas for read distribution and shard the database. Your short code design (random Base62) makes sharding straightforward because codes distribute evenly.
What happens at 1M requests per second?
At this scale, you need multiple Redis clusters (sharded by key prefix or consistent hashing), a load balancer distributing requests across application servers, and database read replicas behind the cache layer. Your Base62 short codes remain the same. The code generation logic does not change. Only the infrastructure underneath scales out.
The interviewer wants to hear you identify which component becomes the bottleneck at each scale point. Your cache handles the read path. The database handles writes. At extreme scale, writes to a single database become the next bottleneck.
Use this in interviews
When discussing caching, reference your hands-on experience. You can say you observed a cache hit serving a redirect in microseconds while a cache miss required a full database round trip. Concrete experience beats memorized diagrams.
- Exit the Redis CLI by typing:
exit
You have now seen the full read-through cache lifecycle from the inside. You can explain cache hits, cache misses, TTL expiration, and scaling strategies from personal experience. Next up, put your skills to the test with a Secret Mission that tackles the second most-asked system design interview question.
Secret mission
Add a Rate Limiter
You just built the #1 most-asked system design interview question. Now tackle #2: a sliding window rate limiter using Redis sorted sets. This protects your API from abuse and gives you a second high-value system design answer ready for whiteboarding.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. The only resource using system memory is the Redis Docker container. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Redis Docker container (running on port 6379).
- Python virtual environment (.venv folder inside your project).
- Django project folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the project.
- Your Redis container stays running and uses a small amount of memory.
- Your project folder and virtual environment remain on your Desktop.
- You can restart the Django dev server any time with python manage.py runserver.
Pause - I'll come back to this later
Stop the Redis container to free up memory. Your project files stay on disk so you can resume later.
- Find your Redis container ID by running:
docker ps
- Stop the Redis container by running:
docker stop <container_id>
Replace <container_id> with the container ID shown in the docker ps output.
- When you return, restart Redis with the same run command from Step 1.
Delete - I don't want to use this again
Remove all project resources completely. This frees up disk space and stops all related processes.
First, stop and remove the Redis container.
- Find your Redis container ID by running:
docker ps
- Stop and remove the container by running:
docker stop <container_id>
docker rm <container_id>
Replace <container_id> with the container ID shown in the docker ps output.
Next, delete the project folder.
macOS
- Remove the project folder by running:
rm -rf ~/Desktop/urlshortener
Windows
- Remove the project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\urlshortener
This removes the virtual environment and all project files in one step.
That's a Wrap!
That's a Wrap!
You just built the #1 most-asked system design interview question from scratch. You now have a working URL shortener API with a Redis caching layer that you can whiteboard and explain from personal experience.
You've learned how to:
- Build a complete URL shortener REST API with Django and DRF, including Base62 short code generation with collision-retry logic.
- Implement the read-through cache pattern with Redis to eliminate database hits for hot URLs.
- Track click analytics per shortened URL using atomic database updates.
- Secret Mission: Built a sliding window rate limiter using Redis sorted sets, covering the #2 most-asked system design interview question.
Ready to quiz yourself?