Unique, but obfuscated URLs in Django

When building a website, sometimes you want a URL for a specific piece of data, but there isn't a clear field that should be slugified. This usually happens when the name or title of the data might get updated in the future which would change the URL slug. But, "cool URLs never change"!

Let's pretend that you are building an e-commerce system. You want to have a detail page for each product. However, just using a slug based on the product name would mean the URL might change if the product's name ever got updated.

from django.db import models

class Product(models.Model):
    id = models.BigAutoField()  # making the `id` explicit
    name = models.CharField(max_length=255)

The easiest approach in this situation would be to use the database's auto-generated primary key for the URL. By default in Django, the primary key is a BigAutoField which basically means it starts at 1 for the first product and increments up for each new piece of data to a very, very large number (e.g. 9223372036854775807) So, unless you are Instagram you are probably going to be fine.

WARNING

However, using an integer in the URL exposes private information about your data that you probably want to keep private. It also allows malicious users to easily increment the id to find all the products in your system.

Alice sees that a product is located at /products/123.

Alice then proceeds to look at the next product at /products/124.

Alice has now hacked the mainframe.

Obfuscated identifiers

There are a few options to create unique identifiers, but also stay away from exposing the auto-incrementing integers in your database.

UUID

One approach to create URLs that are obfuscated, but also guaranteed to be unique is use uuid.uuid4 and the UUIDField provided by Django.

import uuid
from django.db import models

class Product(models.Model):
    identifier = models.UUIDField(default=uuid.uuid4, editable=False)

Using a UUID as the primary key is supported by Django, but I tend to use a separate field in addition to the implicit id that Django will use by default. Why? Honestly, it is mostly out of force of habit (and the, maybe?, irrational fear that a UUID will be slower than using an integer in PostgreSQL -- more details in this StackOverflow question). But, you can see an example of using UUID as the primary key in the Django docs if you want to try it out.

Whether it's the actual primary key or not, the urlconf can then look up an object by using the built-in uuid path converter.

The first part, uuid, is the path converter. The second part is the argument passed into the view arguments.

# urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('products/<uuid:identifier>/', views.products),
]
# views.py
from django.shortcuts import render
from product.models import Product

def products(request, identifier):
    product = Product.objects.get(identifier=identifier)
    return render(request, "product.html", context={"product": product})

The upside of UUIDs are that they are basically mathmatically guaranteed to be unique and Django supports them without any additional libraries. The downside is that they make URLs ugly. :shruggie:

nanoid

nanoid is an attempt to have the same quaranteed uniqueness that you get with UUID, but in a more URL-friendly identifier. The Python port looks like a good approach if you are worried about URL collisions.

shortuuid

A second approach is to use shortuuid which I have used in a lot of projects in the past. Especially useful is the ability to pass in a string as a namespace. shortuuid includes a Django model field for ease of use.

from django.db import models
from shortuuid.django_fields import ShortUUIDField

class Product(models.Model):
    identifier = ShortUUIDField(length=8)  # the default length is 22 characters

RandomCharField

A third approach I've been using recently is to use the RandomCharField included in the django-extensions package with a unique constraint on the database field. With a length of 8, there are 3.4 million possible combinations which is probably good enough (until it isn't).

from django.db import models
from django_extensions.db.fields import RandomCharField

class Product(models.Model):
    identifier = RandomCharField(length=8, editable=False, unique=True)

What I do 🌟

Hopefully that gave you some ideas of how to approach creating detail pages for the future. Personally, I tend to:

  • use the default id in the model of BigAutoField
  • add a slug field if it would be useful for SEO purposes
  • use RandomCharField with a unique constraint on the model which gives me clean enough URLs and they are unique enough for my purposes

Related Content

Hi, I'm Adam 👋

I've been a backend programmer for ~20 years in a variety of different languages before I discovered Python 10 years ago and never looked back. alldjango includes all the hard-won experience I've gained over the years building production-scale Django websites.

Feel free to reach out to me on Mastodon or make a GitHub Issue with questions, comments, or bitter invectives.

All code is licensed as MIT.

DigitalOcean Referral Badge

   


Made with 🤟 and built with Coltrane 🎵


© Adam Hill