Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • SlugField, slugify and uniqueness
  • created_at and updated_at done once
  • Abstract base classes versus multi-table inheritance
  • Model mixins you will reuse in every project

Lesson notes

1. The same four fields, everywhere

Look at what Post, Category, Tag and Comment now have in common. Each one wants to know when it was created and when it was last touched, and the ones that appear in a URL also want a slug. Copy those fields into four models and you have four places to keep in sync.

# the copy-paste you want to stop writing
class Post(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    slug = models.SlugField(max_length=200, unique=True)
    ...


class Category(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    slug = models.SlugField(max_length=60, unique=True)
    ...

Python already has the answer - inheritance. Django gives it a database-aware twist, and the important part is choosing the right kind.

2. SlugField, slugify and uniqueness

A slug is the human-readable part of a URL: /post/how-django-orm-works/ instead of /post/17/. SlugField is a CharField with a validator that allows only letters, numbers, hyphens and underscores, and it is indexed by default (db_index=True).

from django.utils.text import slugify

slugify("How the Django ORM works")     # 'how-the-django-orm-works'
slugify("Django 5.0: what's new?")      # 'django-50-whats-new'
slugify("Grusse aus Koln")              # 'grusse-aus-koln'

By default non-ASCII letters are transliterated or dropped, which turns a Cyrillic or Greek title into an empty slug. Pass slugify(title, allow_unicode=True) and set SlugField(allow_unicode=True) if your blog is not written in English.

Generating it once, in save(), is the pattern to memorise. Note the two deliberate choices: only fill it when it is empty, so an existing URL never changes silently and breaks your links, and add a suffix until it is unique.

# common/models.py
from django.db import models
from django.utils.text import slugify


class SluggedModel(models.Model):
    slug = models.SlugField(max_length=200, unique=True, blank=True)

    slug_source_field = "title"

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = self._unique_slug()
        super().save(*args, **kwargs)

    def _unique_slug(self):
        base = slugify(getattr(self, self.slug_source_field))[:190] or "item"
        slug = base
        suffix = 2
        model = self.__class__
        while model.objects.filter(slug=slug).exclude(pk=self.pk).exists():
            slug = f"{base}-{suffix}"
            suffix += 1
        return slug

    def get_absolute_url(self):
        raise NotImplementedError

Two slugs can share a title across models, so a global unique slug is often wrong. Scope it instead:

class Post(models.Model):
    slug = models.SlugField(max_length=200)
    published_date = models.DateTimeField(blank=True, null=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["category", "slug"], name="unique_slug_per_category"
            ),
        ]

3. created_at and updated_at, done once

# common/models.py
class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        abstract = True
        get_latest_by = "created_at"
OptionSet whenEditable in formsRespects your value
auto_now_add=Trueon insert onlynono
auto_now=Trueon every savenono
default=timezone.nowon insert, if unsetyesyes

auto_now is silently skipped by queryset.update(), and with save(update_fields=[...]) you must include the field yourself: post.save(update_fields=["title", "updated_at"]). When you need to control the value - imports, fixtures, tests - use default=timezone.now instead.

4. Abstract base classes versus multi-table inheritance

This is the decision that matters. One keyword in Meta changes what happens in the database.

abstract = True: no table for the base, fields copied down TimeStampedModel no table blog_post title, created_at, updated_at blog_comment body, created_at, updated_at 1 query per read concrete base: a real parent table, joined every time blog_content id, created_at blog_post content_ptr_id blog_video content_ptr_id JOIN always
class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        abstract = True        # <- remove this line and everything changes
Abstract base classMulti-table inheritance
Tablesone per child, fields copiedparent table plus one per child
Readsno joinan implicit join on every access
Query the baseimpossible - it is not a modelContent.objects.all() works
Point a FK at itnoyes
Use whenyou are sharing fields and behaviouryou genuinely need a common table

In practice you want abstract almost always. Multi-table inheritance is a hidden join on every query, and the case it solves - "give me all content, whatever kind" - is usually better served by a plain foreign key or a type field.

There is also proxy inheritance, which adds no fields and no table, only different Python behaviour - a different default manager or Meta.ordering:

class PublishedPost(Post):
    objects = PublishedManager()

    class Meta:
        proxy = True
        ordering = ["-published_date"]

5. The mixins you will reuse in every project

Keep them in the common app from lesson 1.1, each doing exactly one thing:

# common/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone


class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        abstract = True


class SoftDeleteQuerySet(models.QuerySet):
    def alive(self):
        return self.filter(deleted_at__isnull=True)

    def dead(self):
        return self.filter(deleted_at__isnull=False)


class SoftDeleteModel(models.Model):
    deleted_at = models.DateTimeField(null=True, blank=True, editable=False)

    objects = SoftDeleteQuerySet.as_manager()

    class Meta:
        abstract = True

    def delete(self, *args, **kwargs):
        self.deleted_at = timezone.now()
        self.save(update_fields=["deleted_at"])

    def restore(self):
        self.deleted_at = None
        self.save(update_fields=["deleted_at"])


class AuthoredModel(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="%(app_label)s_%(class)s_set",
    )

    class Meta:
        abstract = True

6. Putting it together

# blog/models.py
from common.models import SluggedModel, TimeStampedModel

from .managers import PostQuerySet


class Post(TimeStampedModel, SluggedModel):
    title = models.CharField(max_length=200)
    text = models.TextField()
    published_date = models.DateTimeField(blank=True, null=True)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts"
    )
    category = models.ForeignKey(
        Category, on_delete=models.PROTECT, related_name="posts", null=True, blank=True
    )
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)

    objects = PostQuerySet.as_manager()

    slug_source_field = "title"

    class Meta:
        ordering = ["-published_date"]
        indexes = [models.Index(fields=["-published_date"], name="post_published_idx")]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:post_detail", kwargs={"slug": self.slug})

Two details that trip people up. An abstract parent's Meta is inherited, so if the child defines its own Meta and you want to keep the parent's options, inherit explicitly:

class Post(TimeStampedModel):
    class Meta(TimeStampedModel.Meta):
        abstract = False              # Django resets this for you, but be explicit
        ordering = ["-published_date"]

And changing the base class does generate migrations in every child app, because each child table gets the columns:

python manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0008_post_created_at_post_updated_at.py
    + Add field created_at to post
    + Add field updated_at to post
Migrations for 'comments':
  comments/migrations/0003_comment_created_at_comment_updated_at.py
    + Add field created_at to comment

Since auto_now_add cannot invent a value for existing rows, Django will ask for a one-off default - or you use the three-step pattern from lesson 2.2.

7. Exercise

  1. Create common/models.py with TimeStampedModel and SluggedModel as abstract base classes.
  2. Make Post, Category, Tag and Comment inherit from them, generate the migrations, and inspect one with sqlmigrate to see that no extra table was created.
  3. Switch post_detail to look up by slug instead of pk, add get_absolute_url() and use it in your templates.
  4. Create two posts with the identical title and confirm the second slug ends in -2.
  5. Temporarily remove abstract = True, run makemigrations --dry-run --verbosity 3 and describe what Django wants to create. Then put it back.
  6. Add SoftDeleteModel to Comment and make the detail template show only comments that are alive.

Further reading