Django Slug Tutorial

Will Vincent - Jul 2 '20 - - Dev Community

In this tutorial we will add slugs to a Django website. As noted in the official docs: "Slug is a newspaper term. A slug is a short label for something, containing only letters, numbers, underscores or hyphens. They’re generally used in URLs."

To give a concrete example, assume you had a Newspaper website (such as we'll build in this tutorial). For a story titled "Hello World," the URL would be example.com/hello-world assuming the site was called example.com.

Despite their ubiquity, slugs can be somewhat challenging to implement the first time around, at least in my experience. Therefore we will implement everything from scratch so you can see how the pieces all fit together. If you are already comfortable implementing ListView and DetailView you can jump right to the Slug section.

Complete source code can be found on Github.

Set Up

To start let's navigate into a directory for our code. This can be hosted anywhere on your computer but if you're on a Mac, an easy-to-find location is the Desktop.

$ cd ~/Desktop
$ mkdir newspaper && cd newspaper
Enter fullscreen mode Exit fullscreen mode

To pay homage to Django's origin at a newspaper, we'll create a basic Newspaper website with Articles. If you need help installing Python, Django, and all the rest (see here for in-depth instructions).

On your command line, enter the following commands to install the latest version with Pipenv, create a project called config, set up the initial database via migrate, and then start the local web server with runserver.

$ pipenv install django~=3.0.0
$ pipenv shell
(newspaper) $ django-admin startproject config .
(newspaper) $ python manage.py migrate
(newspaper) $ python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

Don't forget to include that period . at the end of the startproject command! It is an optional step that avoid Django creating an additional directory otherwise.

Navigate to http://127.0.0.1:8000/ in your web browser to see the Django welcome page which confirms everything is configured properly.

Django welcome page

Articles app

Since the focus of this tutorial is on slugs I'm going to simply give the commands and code to wire up this Articles app. Full explanations can be found in my book Django for Beginners!

Let's start by creating an app called articles. Stop the local server with Control+c and use the startapp command to create this new app.

(newspaper) $ python manage.py startapp articles
Enter fullscreen mode Exit fullscreen mode

Then update INSTALLED_APPS within our config/settings.py file to notify Django about the app.

# config/settings.py
INSTALLED_APPS = [
    ...
    'articles.apps.ArticlesConfig', # new
]
Enter fullscreen mode Exit fullscreen mode

Article Model

Create the database model which will have a title and body. We'll also set __str__ and a get_absolute_url which are Django best practices.

# articles/models.py
from django.db import models
from django.urls import reverse

class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('article_detail', args=[str(self.id)])
Enter fullscreen mode Exit fullscreen mode

Now create a migrations file for this change, then add it to our database via migrate.

(newspaper) $ python manage.py makemigrations articles
(newspaper) $ python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Django Admin

The Django admin is a convenient way to play around with models so we'll use it. But first, create a superuser account.

(newspaper) $ python manage.py createsuperuser
Enter fullscreen mode Exit fullscreen mode

And then update articles/admin.py to display our app within the admin.

# articles/admin.py
from django.contrib import admin

from .models import Article

class ArticleAdmin(admin.ModelAdmin):
    list_display = ('title', 'body',)

admin.site.register(Article, ArticleAdmin)
Enter fullscreen mode Exit fullscreen mode

Start up the server again with python manage.py runserver and navigate to the admin at http://127.0.0.1:8000/admin. Log in with your superuser account.

Admin Homepage

Click on the "+ Add" next to the Articles section and add an entry.

Admin Article

URLs

In addition to a model we'll eventually need a URL, view, and template to display an Article page. I like to move to URLs next after models although the order doesn't matter: we need all four before we can display a single page. The first step is to add the articles app to our project-level config/urls.py file.

# config/urls.py
from django.contrib import admin
from django.urls import path, include # new

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('articles.urls')), # new
]
Enter fullscreen mode Exit fullscreen mode

Next we must create the app-level urls.py file.

$ touch articles/urls.py
Enter fullscreen mode Exit fullscreen mode

We'll have a ListView to list all articles and a DetailView for individual articles.

Note that we're referencing two views that have yet to be created: ArticleListView and ArticleDetailView. We'll add them in the next section.

# articles/urls.py
from django.urls import path

from .views import ArticleListView, ArticleDetailView

urlpatterns = [
    path('<int:pk>', ArticleDetailView.as_view(), name='article_detail'),
    path('', ArticleListView.as_view(), name='article_list'),
]
Enter fullscreen mode Exit fullscreen mode

Views

For each view we specify the related model and appropriate not-yet-created template.

# articles/views.py
from django.views.generic import ListView, DetailView

from .models import Article

class ArticleListView(ListView):
    model = Article
    template_name = 'article_list.html'


class ArticleDetailView(DetailView):
    model = Article
    template_name = 'article_detail.html'
Enter fullscreen mode Exit fullscreen mode

Templates

Finally, we come to the last step: templates. By default Django will look within each app for a templates directory. That structure in our case would be articles/templates/template.html.

Type Control+c on the command line and create the new templates directory.

(newspaper) $ mkdir articles/templates
Enter fullscreen mode Exit fullscreen mode

Now add the two new templates.

(newspaper) $ touch articles/templates/article_list.html
(newspaper) $ touch articles/templates/article_detail.html
Enter fullscreen mode Exit fullscreen mode

For our list page we loop over object_list which is provided by ListView. And we add an a href by using the get_absolute_url method added to the model.

<!-- article_list.html -->
<h1>Articles</h1>
{% for article in object_list %}
  <ul>
    <li><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></li>
  </ul>
{% endfor %}
Enter fullscreen mode Exit fullscreen mode

The detail view outputs our two fields--title and body--using the object default provided by DetailView. You can, and probably should, rename both object_list in the ListView and object in the DetailView to be more descriptive.

<!-- article_detail.html -->
<div>
  <h2>{{ object.title }}</h2>
  <p>{{ object.body }}</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Make sure the server is running--python manage.py runserver--and check out both our pages in your web browser.

The list of all articles is available at http://127.0.0.1:8000/.

ListView

And the detail view for our single article is at http://127.0.0.1:8000/1.

DetailView

Slugs

Finally we come to slugs. Ultimately we want our article title to be reflected in the URL. In other words, "A Day in the Life" should have the URL of http://127.0.0.1:8000/a-day-in-the-life.

There are only two steps required: updating our articles/models.py file and articles/urls.py. Ready? Let's go...

In our model, we can add Django's built-in SlugField. But we must also--and this is the part that typically trips people up--update get_absolute_url as well. That's where we pass in the value used in our URL. Currently it passes in the id for the article an args, so 1 for our first article. We need to change that over to a keyword argument, kwargs, for our slug.

# articles/models.py
from django.db import models
from django.urls import reverse


class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    slug = models.SlugField() # new

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('article_detail', kwargs={'slug': self.slug}) # new
Enter fullscreen mode Exit fullscreen mode

Moving along let's add a migration file since we've updated our model.

(newspaper) $ python manage.py makemigrations articles
You are trying to add a non-nullable field 'slug' to article without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py
Enter fullscreen mode Exit fullscreen mode

Ack! What is this?! It turns out we already have data in our database, our single Article, so we can't just willy-nilly add a new field on. Django is helpfully telling us that we either need to a a one-off default of null or add it ourself. Hmmm.

For this very reason, it is generally good advice to always add new fields with either null=True or with a default value.

Let's take the easy approach of setting null=True for now. So type 2 on the command line. Then add this to our slug field.

# articles/models.py
from django.db import models
from django.urls import reverse


class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    slug = models.SlugField(null=True) # new

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('article_detail', kwargs={'slug': self.slug})
Enter fullscreen mode Exit fullscreen mode

Try to create a migrations file again and it will work.

(newspaper) $ python manage.py makemigrations articles
Enter fullscreen mode Exit fullscreen mode

Go ahead and migrate the database as well to apply the change.

(newspaper) $ python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

But if you think about it, what we've done is create a null value for our slug. We have to go into the admin to set it properly. Start up the local server, python manage.py runserver, and go to the Article page in the Admin. The Slug field is empty.

Empty Slug

Manually add in our desired value a-day-in-the-life and click "Save."

Add Slug

Ok, last step is to update articles/urls.py so that we display the slug keyword argument in the URL itself. Luckily that just means swapping out <int:pk> for <slug:slug>.

# articles/urls.py
from django.urls import path

from .views import ArticleListView, ArticleDetailView

urlpatterns = [
    path('<slug:slug>', ArticleDetailView.as_view(), name='article_detail'), # new
    path('', ArticleListView.as_view(), name='article_list'),
]
Enter fullscreen mode Exit fullscreen mode

And we're done! Go to the list view page at http://127.0.0.1:8000/ and click on the link for our article.

Slug URL

And there it is with our slug as the URL! Beautiful.

Unique and Null

Moving forward, do we really want to allow a null value for a slug? Probably not as it will break our site! Another consideration is: what happens if there are identical slugs? How will that resolve itself? The short answer is: not well.

Therefore let's change our slug field so that null is not allowed and unique values are required.

# articles/models.py
from django.db import models
from django.urls import reverse


class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    slug = models.SlugField(null=False, unique=True) # new

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('article_detail', kwargs={'slug': self.slug})
Enter fullscreen mode Exit fullscreen mode

Make migration/migrate. Need empty for past entry.

(newspaper) $ python manage.py makemigrations articles
You are trying to change the nullable field 'slug' on article to non-nullable without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Ignore for now, and let me handle existing rows with NULL myself (e.g. because you added a RunPython or RunSQL operation to handle NULL values in a previous data migration)
 3) Quit, and let me add a default in models.py
Select an option:
Enter fullscreen mode Exit fullscreen mode

Select 2 since we can manually handle the existing row ourself, and in fact, already have. Then migrate the database.

(newspaper) $ python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

PrePopulated Fields

Manually adding a slug field each time quickly becomes tedious. So we can use a prepopulated_field in the admin to automate the process for us.

Update articles/admin.py as follows:

# articles/admin.py
from django.contrib import admin

from .models import Article

class ArticleAdmin(admin.ModelAdmin):
    list_display = ('title', 'body',)
    prepopulated_fields = {'slug': ('title',)} # new

admin.site.register(Article, ArticleAdmin)
Enter fullscreen mode Exit fullscreen mode

Now head over to the admin and add a new article. You'll note that as you type in the Title field, the Slug field is automatically populated. Pretty neat!

Signals, Lifecycle Hooks, Save, and Forms/Serializers

In the real world, it's unlikely to simply provide admin access to a user. You could, but at scale it's definitely not a good idea. And even on a small scale, most non-technical users will find a web interface more appealing.

So...how to auto-populate the slug field when creating a new Article. It turns out Django has a built-in tool for this called slugify!

But how to use slugify? In practice, it's common to see this done with a Signal. But I would argue--as would Django Fellow Carlton Gibson--that this is not a good use of signals because we know both the sender and receiver here. There's no mystery. We discuss the proper use of signals at length in our Django Chat Podcast episode on the topic.

An alternative to signals is to use a lifecycle hook via something like the django-lifecycle package. Lifecycle hooks are an alternative to Signals that provide similar functionality with less indirection.

Another common way to see this implemented is by overriding the Article model's save method. This also "works" but is not the best solution. Here is one way to do that.

# articles/models.py
from django.db import models
from django.template.defaultfilters import slugify # new
from django.urls import reverse

class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    slug = models.SlugField(null=False, unique=True)

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('article_detail', kwargs={'slug': self.slug})

    def save(self, *args, **kwargs): # new
        if not self.slug:
            self.slug = slugify(self.title)
        return super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

The best solution, in my opinion, is to create the slug in the form itself. This can be done by overriding the form's clean method so that cleaned_data has the slug, or JavaScript can be used to auto-populate the field as is done in the Django admin itself. If you're using an API, the same approach can be applied to the serializer.

This solution does not rely on a custom signal and handles the slug before it touches the database. In the words of Carlton Gibson, who suggested this approach to me, win-win-win.

Words of Caution

A quick word of caution on using slugs in a large website. Despite requiring an Article to be unique it is almost inevitable to have naming conflicts using slugs. However slugs do seem to have good SEO properties. A good compromise is to combine a slug with a UUID or a username. The ultimate URL would therefore be {{ uuid }}/{{ slug }} or {{ username }}/{{ slug }}. If you look at Github, for example, the source code for this tutorial is at https://github.com/wsvincent/django-slug-tutorial which is a username + slug pattern. While you could also use an id + a slug, using ids is often a security concern and in my opinion should be avoided for production websites.

Next Steps

If you want to compare your code with the official source code, it can be found on Github.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .