2025, Nov 05 01:00
How to Generate Stable Django Parler Slugs: post_save + Unidecode to Handle Non-ASCII Titles
Learn why slugify drops Cyrillic in Django Parler, and fix it with post_save and Unidecode. Generate predictable multilingual slugs like title-pk reliably.
Generating clean, predictable slugs in a multilingual Django project can look trivial until you add Parler into the mix. A common pattern like “title-pk” may degrade into “-None”, “-9”, or just the numeric tail when titles contain non‑ASCII characters. Below is a compact walkthrough of what goes wrong, how to confirm it with simple debugging, and how to fix it reliably without bending your models.
The setup and the unexpected behavior
The model holds a translated title and a plain slug. The goal is to produce a slug of the form “title-pk” using the Ukrainian translation for the title.
from django.db import models
from django.utils.translation import gettext_lazy as _
from parler.models import TranslatableModel, TranslatedFields
from django.utils.text import slugify
class EventEntry(TranslatableModel):
translations = TranslatedFields(
title=models.CharField(max_length=255, verbose_name=_("Event title")),
body=models.TextField(verbose_name=_("Event body")),
)
slug = models.SlugField(blank=True, max_length=255, verbose_name=_("Event slug"))
def save(self, *args, **kwargs):
if not self.slug:
uk_label = self.safe_translation_getter("title", language_code="uk")
pre_slug = slugify(uk_label)
self.slug = f"{pre_slug}-{self.pk}"
super().save(*args, **kwargs)
This looked fine at a glance but kept producing values like “-None” or “-pk”. The first intuition is right: pk isn’t available before the initial insert. Flipping the order by calling super().save() first seemed promising, but the logs still showed an empty base when the title contained Cyrillic characters.
import logging
class EventEntry(TranslatableModel):
translations = TranslatedFields(
title=models.CharField(max_length=255, verbose_name=_("Event title")),
body=models.TextField(verbose_name=_("Event body")),
)
slug = models.SlugField(blank=True, max_length=255, verbose_name=_("Event slug"))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if not self.slug:
uk_label = self.safe_translation_getter("title", language_code="uk")
pre_slug = slugify(uk_label)
logging.debug("[DEBUG] Event pre_slug: %s", pre_slug)
self.slug = f"{pre_slug}-{self.pk}"
logging.debug("[DEBUG] Event slug: %s", self.slug)
super().save(*args, **kwargs)
[DEBUG] Event pre_slug:
[DEBUG] Event slug: -9
What is really happening
There are two interacting facts. First, generating the slug inside save() before the initial write gives you pk = None. Second, even if you call super().save() first, the transliterated title can collapse to an empty string because slugify() does not transliterate non‑ASCII characters. With a Cyrillic title, slugify() strips the entire word, leaving only the trailing -pk. That’s why the debug output captured an empty base and a slug like “-9”. A separate trace taken around a signal showed the same effect in another form.
[DEBUG] base_slug: тест-11
[DEBUG] instance.slug: 11
The raw base included the Cyrillic word and the id, but after slugify() the letters disappeared and only the number survived.
The fix that works: post_save and transliteration
The robust move is to wait until the object is persisted and its primary key exists, then transliterate the base string to ASCII before slugify(). This is where post_save plus unidecode does the job cleanly.
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify
from unidecode import unidecode
@receiver(post_save, sender=EventEntry)
def build_slug_post_save(sender, instance, **kwargs):
if not instance.slug:
uk_label = instance.safe_translation_getter("title", language_code="uk")
if uk_label:
raw = f"{uk_label}-{instance.pk}"
final_slug = slugify(unidecode(raw))[:255]
logging.debug("[DEBUG] instance.slug: %s", final_slug)
instance.slug = final_slug
instance.save(update_fields=["slug"])
This sequence guarantees a valid pk and turns Cyrillic into a safe Latin representation before slugification, producing the expected “test-11” from “тест-11”.
Why you want to know this
In multilingual APIs, slug generation sits on the boundary between data modeling and URL semantics. Parler defers translated content to its own storage; your model’s first save() call doesn’t guarantee a ready-to-use translated field, and slugify() won’t salvage non‑ASCII characters. Without explicit transliteration and the right lifecycle hook, your slugs silently degrade, break expectations, and become fragile in routing.
Practical takeaways
Trace what you actually have at runtime. Simple print‑style logging makes it obvious whether you’re holding a None, an empty string, or a stripped value after slugify(). Generate the slug after the row exists so pk is defined, and transliterate before slugifying to preserve meaningful text from non‑ASCII titles. With post_save and unidecode in place, “title-pk” stays stable across languages.