How to Build a URL Shortener App with Django
Build a production-ready Django URL shortener with validation, collision-safe codes, redirects, security controls, analytics, and deployment guidance.

A Django URL shortener stores a destination URL with a short code, then redirects visitors from /s/<code>/ to the saved destination. The implementation has four parts: a database model, a form that validates destinations, URL patterns, and views for creating and resolving links.
This tutorial builds a complete small application. It uses HTTP and HTTPS destinations, random collision-safe codes, disabled and expired-link handling, a temporary redirect by default, and privacy-conscious click counts. You can change each policy for your service.
What you are building
The request flow is:
- A user submits a long URL.
- Django validates the scheme and parses the URL.
- The application creates a unique short code and saves the mapping.
- Django returns the short URL.
- A visitor requests the short URL, and Django looks up the code and redirects.
| Decision | Choice in this tutorial | Why you may change it |
|---|---|---|
| Destination schemes | http and https |
Reject other schemes to avoid JavaScript, file, and custom-protocol abuse. |
| Code format | Seven random URL-safe characters | Increase length for a larger public service or allow custom aliases. |
| Unknown code | HTTP 404 | Use a branded page if that fits your product. |
| Expired or disabled code | HTTP 410 | Use 404 if you do not want to reveal that a link existed. |
| Redirect | 302 temporary | Use 301 or 308 only when the destination is permanently fixed. |
| Analytics | Counter and last-click timestamp | Add privacy-preserving aggregate data only when needed. |
1. Create the Django project
Use a virtual environment and install Django. These commands work with a supported Django release; check the official release documentation before pinning a version.

python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell: .venv\\Scripts\\Activate.ps1
python -m pip install --upgrade pip
python -m pip install Django
django-admin startproject shortener .
python manage.py startapp links
python manage.py migrate
python manage.py runserver
Add the app to shortener/settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'links',
]
2. Define the URL mapping model
Django models represent application data, as described in the Django project overview. Create links/models.py:
from django.db import models
from django.utils import timezone
class ShortLink(models.Model):
code = models.CharField(max_length=16, unique=True, db_index=True)
destination = models.URLField(max_length=2048)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True)
click_count = models.PositiveBigIntegerField(default=0)
last_clicked_at = models.DateTimeField(null=True, blank=True)
def is_available(self):
if not self.is_active:
return False
return self.expires_at is None or self.expires_at > timezone.now()
def __str__(self):
return self.code
unique=True creates a database-enforced uniqueness rule. Application code still handles a race where two requests generate the same code at nearly the same time.
python manage.py makemigrations links
python manage.py migrate
3. Validate and generate codes
RFC 3986 defines generic URI syntax and explains why URI data must be interpreted carefully. It does not define your product’s complete allowlist, so make the policy explicit. This example accepts only absolute HTTP(S) URLs and rejects credentials embedded in the URL.
Create links/forms.py:
from urllib.parse import urlparse
from django import forms
from .models import ShortLink
class ShortLinkForm(forms.ModelForm):
class Meta:
model = ShortLink
fields = ('destination', 'expires_at')
widgets = {
'expires_at': forms.DateTimeInput(
attrs={'type': 'datetime-local'}
)
}
def clean_destination(self):
value = self.cleaned_data['destination'].strip()
parsed = urlparse(value)
if parsed.scheme.lower() not in {'http', 'https'}:
raise forms.ValidationError('Use an http:// or https:// URL.')
if not parsed.netloc:
raise forms.ValidationError('Enter an absolute URL with a host.')
if parsed.username or parsed.password:
raise forms.ValidationError('URLs containing embedded credentials are not allowed.')
return value
Generate codes with Python’s cryptographic random source. The retry loop handles the unlikely collision that remains after the database constraint.
import secrets
import string
from django.db import IntegrityError
ALPHABET = string.ascii_letters + string.digits
def create_unique_link(*, destination, expires_at=None, attempts=10):
from .models import ShortLink
for _ in range(attempts):
code = ''.join(secrets.choice(ALPHABET) for _ in range(7))
try:
return ShortLink.objects.create(
code=code,
destination=destination,
expires_at=expires_at,
)
except IntegrityError:
continue
raise RuntimeError('Could not allocate a unique short code')
Seven base-62 characters provide a large namespace for a small service. Increase the length when the number of links, guessing risk, or privacy requirements grow. Never use sequential database IDs as public codes if enumeration would expose link volume or private data.
4. Add forms, views, and routes
Django URLconfs evaluate patterns in order and call the first matching callback. Named routes allow reversing instead of hard-coding paths; see the URL dispatcher documentation.
Create links/views.py:
from django.db.models import F
from django.http import Http404, HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from .forms import ShortLinkForm
from .models import ShortLink
from .services import create_unique_link
def create_link(request):
if request.method == 'POST':
form = ShortLinkForm(request.POST)
if form.is_valid():
link = create_unique_link(
destination=form.cleaned_data['destination'],
expires_at=form.cleaned_data.get('expires_at'),
)
return render(request, 'links/created.html', {'link': link})
else:
form = ShortLinkForm()
return render(request, 'links/create.html', {'form': form})
def resolve_link(request, code):
link = ShortLink.objects.filter(code=code).first()
if link is None:
raise Http404('Short link not found')
if not link.is_available():
return HttpResponse('This short link is no longer available.', status=410)
ShortLink.objects.filter(pk=link.pk).update(
click_count=F('click_count') + 1,
last_clicked_at=timezone.now(),
)
return redirect(link.destination, permanent=False)
Put the generator in links/services.py using the function shown above. Then create links/urls.py:
from django.urls import path
from . import views
app_name = 'links'
urlpatterns = [
path('', views.create_link, name='create'),
path('s/<str:code>/', views.resolve_link, name='resolve'),
]
Include it from shortener/urls.py:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('links.urls')),
]
5. Add minimal templates
Create links/templates/links/create.html:
<!doctype html>
<title>Create a short link</title>
<h1>Create a short link</h1>
<form method='post'>
{% csrf_token %}
{{ form.as_p }}
<button type='submit'>Shorten URL</button>
</form>
Create links/templates/links/created.html:
<!doctype html>
<title>Short link created</title>
<h1>Short link created</h1>
<p><a href='{% url "links:resolve" link.code %}'>{{ request.scheme }}://{{ request.get_host }}/s/{{ link.code }}/</a></p>
<p>Destination: {{ link.destination }}</p>
In production, consider displaying the generated URL from a configured public origin rather than trusting request headers. If you construct an origin from the request, use Django’s validated request.get_host(); do not read the raw Host value from request.META. Django documents that bypassing get_host() bypasses host validation.
6. Choose link policies deliberately
Random versus custom codes
Random codes avoid namespace squatting and make creation simple. Custom aliases improve readability but require normalization, reserved-word checks, authorization, and a clear conflict error. Keep the database uniqueness constraint for both approaches.
Persistent versus expiring links
An expiry timestamp supports campaign links and temporary sharing. Check expiry at redirect time, as the example does, rather than relying only on a cleanup job. A periodic job can delete old rows later.
Public versus private links
A public resolver should assume codes will be scanned. Do not store secrets in destinations, expose private analytics, or treat an unguessable code as authentication. For private links, require an authenticated owner and add access controls before redirecting.
Redirect status
A 302 is a safe default while destinations can change. A 301 or 308 may be cached by browsers and intermediaries, so use them only when permanence is intentional. The choice is an application policy rather than a Django requirement.
7. Production security checklist
- Set
DEBUG = Falseand configure a realSECRET_KEY. - Set
ALLOWED_HOSTSto the exact domains serving the application. Django’s host validation is applied throughrequest.get_host(). - Serve the application over HTTPS and verify
SECURE_SSL_REDIRECT, secure cookies, HSTS, and proxy settings for your supported Django version. The settings reference documents HTTPS redirection behavior. - Use CSRF protection on the creation form and never disable it globally.
- Rate-limit link creation and redirect requests. Public shorteners attract automated abuse.
- Consider destination reputation checks, reporting, malware screening, and an abuse contact.
- Limit URL length and reject credentials, unsupported schemes, and malformed hosts.
- Log operational failures without logging full sensitive query strings unnecessarily.
- Back up the database and monitor storage growth.
8. Performance and reliability
The redirect path should be a single indexed lookup by code. Keep the resolver view free of network calls: redirecting should not fetch or inspect the destination synchronously. Use a production WSGI or ASGI server behind a reverse proxy, a connection-pooled database, and a cache only after measuring database load.
The counter update uses F() so concurrent requests increment the value atomically at the database level. If exact counts are not required, queue analytics events and update aggregates asynchronously. For high traffic, separate redirect availability from analytics: a failed analytics write should not prevent a valid redirect.
Cache behavior follows your redirect policy. Temporary redirects reduce the risk of stale destinations. If you cache resolver responses, include the code and active/expiry state in your invalidation plan. Database indexes, short transactions, and a health check for the database are usually more valuable than premature application-level caching.
9. Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
no such table: links_shortlink |
Migrations were not created or applied. | Run python manage.py makemigrations links, then python manage.py migrate. |
| Every URL is rejected | The submitted value is relative or uses a non-HTTP scheme. | Submit an absolute https:// or http:// URL and keep the policy explicit. |
| Duplicate key or integrity error | A generated code collided. | Keep the unique constraint and retry generation inside a bounded loop. |
| 404 for a valid-looking short URL | The route is missing, included under another prefix, or has a trailing-slash mismatch. | Check both URLconfs and use the named route to generate links. |
| 410 response | The link is disabled or expired. | Inspect is_active and expires_at; decide whether 410 or 404 fits your disclosure policy. |
| Bad Request: Invalid HTTP_HOST header | The host is not in ALLOWED_HOSTS. |
Add the exact production hostname and keep host validation enabled. |
| Redirect loops behind a proxy | Django does not know the proxy-terminated request was HTTPS. | Configure forwarded-protocol handling in the proxy and Django deployment settings according to your hosting setup. |
| Counts are lower than expected | Browsers, crawlers, or cache layers may not reach Django for every visit. | Treat the counter as an application-level signal, not a billing-grade measurement. |
10. Test the main behaviors
A small test suite protects the policies that matter. Add tests for valid and invalid destinations, collisions, unknown codes, expiry, disabled links, and redirect status. A representative test looks like this:

from django.test import TestCase
from django.urls import reverse
from .models import ShortLink
class ResolveLinkTests(TestCase):
def test_redirects_active_link(self):
link = ShortLink.objects.create(code='abc1234', destination='https://example.com')
response = self.client.get(reverse('links:resolve', args=[link.code]))
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], link.destination)
def test_unknown_code_is_404(self):
response = self.client.get(reverse('links:resolve', args=['missing']))
self.assertEqual(response.status_code, 404)
Or skip the browser setup
If your goal is to capture the shortened page rather than operate a browser yourself, ScreenshotNeo provides a one-call website screenshot API. The API accepts a URL and returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Should I use a database transaction when creating a code?
The unique database constraint is essential. A transaction can group related writes, but collision handling still needs a retry because two requests can generate the same candidate.
Can the shortener accept any URI scheme?
It can, but a public redirect service usually should not. Start with HTTP and HTTPS and document any additional schemes only after reviewing their security implications.
Is a short code an access password?
No. Treat codes as public identifiers. Add authentication and authorization when a destination must remain private.
When should I add Redis or a task queue?
Add them when measured traffic or analytics workloads require them. Keep the first resolver path as a database lookup plus redirect so its behavior stays easy to reason about.
How should I handle abuse reports?
Provide a report mechanism, retain enough metadata to investigate within your privacy policy, and define how links are disabled. Reputation checks and rate limits should run before a public launch.


