How to Slugify a String in Python: Django vs python-slugify

By · · 6 min read

Key takeaways

  • Django's slugify is ASCII-only and lossy: it deletes ß and Ł (Straße in Łódź → strae-in-odz) and keeps underscores, so snake_case titles stay snake_case.
  • python-slugify transliterates almost everything, but it turns straight apostrophes into separators (don-t) and reads Japanese kanji with Chinese pronunciations (東京タワー → dong-jing-tawa).
  • A 15-line function using unicodedata.normalize('NFKD') plus a small character map beats both on English and European titles, with no dependency.
  • For non-Latin scripts, choose deliberately: python-slugify for Cyrillic and Greek, allow_unicode=True for native-script URLs, or an ID fallback. Never save an empty slug.

Python has two standard answers to "how do I make a URL slug": Django's built-in slugify and the python-slugify package. Both are widely used, and both have edge cases that can quietly break your URLs. Below, both are tested on the same inputs alongside a dependency-free function, so you can pick one based on results rather than habit.

A dependency-free slugify function

import re
import unicodedata

# Letters with no Unicode decomposition: normalization alone can't fold them
CHARS = str.maketrans({"ß": "ss", "æ": "ae", "ø": "o", "đ": "d", "ł": "l",
                       "œ": "oe", "þ": "th", "ð": "d", "ı": "i"})

def slugify(text: str, separator: str = "-", max_length: int = 60) -> str:
    text = text.lower().translate(CHARS)
    text = unicodedata.normalize("NFKD", text)               # é → e + ◌́ , fi → fi
    text = "".join(ch for ch in text if not unicodedata.combining(ch))
    text = re.sub(r"['’]", "", text)                          # don't → dont
    text = text.replace("&", " and ")
    text = re.sub(r"[^a-z0-9]+", separator, text).strip("-_")
    if len(text) > max_length:                                # cut at a word boundary
        cut = text[: max_length + 1]
        last = cut.rfind(separator)
        text = cut[:last] if last > 0 else text[:max_length]
    return text

It uses only the standard library and works on Python 3.6 and later.

How it works, briefly:

  • str.maketrans map first. ß, ł, ø, æ, and œ are letters in their own right, not accented versions of other letters, so normalization can't decompose them. Without the map, they get deleted.
  • NFKD normalization splits é into e plus a combining accent. The compatibility form (the K) also unfolds typographic ligatures such as fi, which are common in text pasted from PDFs.
  • unicodedata.combining identifies the accent characters so they can be dropped.
  • Apostrophes are deleted, not replaced, so contractions stay as one word. Both the straight ' and the curly ’ are handled, because CMS editors usually produce the curly one.
  • [^a-z0-9]+ collapses every run of punctuation, whitespace, and symbols into a single separator.
  • Truncation backs up to the last separator so a word is never cut in half.

The three approaches, tested

Same inputs, three implementations: the function above, python-slugify 8.0.4 with default options, and Django 4.2's django.utils.text.slugify, in both default and allow_unicode=True modes. Tested in October 2026.

Input DIY function python-slugify Django Django, allow_unicode
Crème Brûlée: A How-To creme-brulee-a-how-to creme-brulee-a-how-to creme-brulee-a-how-to crème-brûlée-a-how-to
Don't Stop Me Now dont-stop-me-now don-t-stop-me-now dont-stop-me-now dont-stop-me-now
Rock & Roll rock-and-roll rock-roll rock-roll rock-roll
Straße in Łódź strasse-in-lodz strasse-in-lodz strae-in-odz straße-in-łódź
financial flow financial-flow financial-flow financial-flow financial-flow
Tiếng Việt cho người mới tieng-viet-cho-nguoi-moi tieng-viet-cho-nguoi-moi tieng-viet-cho-nguoi-moi tiếng-việt-cho-người-mới
snake_case_title snake-case-title snake-case-title snake_case_title snake_case_title
C++ vs C# c-vs-c c-vs-c c-vs-c c-vs-c
Привет мир (empty) privet-mir (empty) привет-мир
東京タワー (empty) dong-jing-tawa (empty) 東京タワー

What the results mean

Django's slugify is lossy for European text

Django's implementation normalizes to NFKD and then encodes to ASCII with errors="ignore", which silently deletes every character without an ASCII decomposition. That's why "Straße in Łódź" becomes strae-in-odz. For a German, Polish, Danish, or Norwegian site, that's a real bug. The source looks like this:

value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
value = re.sub(r"[^\w\s-]", "", value.lower())
return re.sub(r"[-\s]+", "-", value).strip("-_")

Because \w matches underscores, Django also keeps underscores, so snake_case_title stays as-is. If your titles can contain underscores, you get mixed-separator URLs, which is worse than either convention on its own (why hyphens win).

Django does handle apostrophes well. [^\w\s-] deletes them without inserting a separator, so don't becomes dont.

python-slugify transliterates everything, sometimes wrongly

python-slugify runs text through text-unidecode, a character-by-character transliteration table. That makes Cyrillic work (privet-mir), and it's the main reason to use the package.

But character-by-character transliteration has a known failure mode with CJK text. Kanji are shared between Chinese and Japanese with different readings, and the table uses Mandarin pinyin. 東京 (Tokyo) becomes dong-jing, its Chinese reading, which no Japanese reader would recognize. If you publish in Japanese, a native-script slug or a hand-written romanized one is better than a confidently wrong one.

Two more defaults to know:

  • Straight apostrophes become separators: Don't → don-t. Oddly, the curly ’ is handled fine (Don’t → dont), so results depend on which quote your editor typed. Fix it with replacements=[["'", ""]].
  • German umlauts fold to single letters: Ä Ö Ü → a-o-u, not the ae-oe-ue German speakers expect. Use replacements for locale-specific rules.

python-slugify's options cover most needs without wrapper code:

from slugify import slugify

slugify("The quick brown fox jumps over the lazy dog", max_length=20, word_boundary=True)
# 'the-quick-brown-fox'

slugify("The quick brown fox", stopwords=["the"])
# 'quick-brown-fox'

slugify("C++ vs C#", replacements=[["++", " plus plus "], ["#", " sharp "]])
# 'c-plus-plus-vs-c-sharp'

slugify("Привет мир", allow_unicode=True)
# 'привет-мир'

Watch the import name: the package is installed as python-slugify but imported as slugify. A different, unrelated package called slugify exists on PyPI. If from slugify import slugify behaves strangely, check which one is installed with pip show python-slugify.

The DIY function wins on Western text and fails loudly elsewhere

For English and European languages, the 15-line function produces the best results of the three: it keeps contractions together, spells out &, handles ß and Ł, and normalizes underscores. On Cyrillic and CJK it returns an empty string. That's not a good result, but it's an obvious failure you can catch, unlike strae-in-odz or dong-jing-tawa, which look like valid slugs.

Which one should you use?

Your situation Use
English or Western European content, no dependencies wanted The DIY function
A Django project with English-only titles Django's slugify, after checking that titles can't contain underscores or ß-type letters
Cyrillic, Greek, or mixed-script content python-slugify
You want native-script URLs (Russian, Arabic, Japanese) Django's slugify(..., allow_unicode=True) or python-slugify with allow_unicode=True
SEO-critical pages Any of the above as a default, then an editor writes the final slug

Whatever you choose, guard against empty slugs:

slug = slugify(title) or f"post-{post_id}"

Unicode slugs in Django URLs

If you enable allow_unicode=True, your URL patterns have to accept it. Django's built-in <slug:...> path converter matches only [-a-zA-Z0-9_]+, so /blog/привет-мир/ returns a 404 with the default converter. Use <str:slug> instead, or register a custom converter. On the model side, SlugField(allow_unicode=True) is required for validation to accept the value.

Browsers display Unicode paths natively, but those paths become percent-encoded when copied into plain-text contexts. The trade-offs are covered in international URLs and transliteration.

Making slugs unique

Two posts with the same title need different slugs. A common pattern is to append a counter:

def unique_slug(base: str, exists) -> str:
    slug, n = base, 2
    while exists(slug):
        slug, n = f"{base}-{n}", n + 1
    return slug

# Django:
unique_slug(slugify(title), lambda s: Post.objects.filter(slug=s).exists())

Back it with unique=True on the field. The existence check alone has a race condition when two records are saved at the same moment, and the database constraint is what actually guarantees uniqueness.

Check results before you ship

Whichever implementation you use, check a sample of real titles before a migration. The bulk slug generator converts a pasted list or CSV in your browser and exports a title,slug mapping you can compare against your Python output. If you're renaming existing URLs, follow the bulk slug migration checklist so old links redirect correctly.

Frequently asked questions

How do I slugify a string in Python without a library?
Lowercase the text, normalize it with unicodedata.normalize('NFKD', text), drop combining characters with unicodedata.combining, then replace runs of non-alphanumeric characters with a hyphen using re.sub(r'[^a-z0-9]+', '-', text).strip('-'). Map ß, ł, ø, æ, and œ explicitly first, because they have no Unicode decomposition.
What is the difference between Django slugify and python-slugify?
Django's django.utils.text.slugify strips accents and deletes any character it can't convert to ASCII, and it keeps underscores. python-slugify (imported as from slugify import slugify) uses the text-unidecode library to transliterate nearly every script, converts underscores to hyphens, and supports max_length, word_boundary, stopwords, and custom replacements.
Does Django slugify support Unicode?
Yes, with slugify(value, allow_unicode=True). It then keeps letters from any script, so Привет мир becomes привет-мир, and only strips punctuation. In URL patterns, use the <str:...> converter or a custom one, because Django's built-in <slug:...> converter only matches ASCII letters, digits, hyphens, and underscores.
How do I limit slug length in python-slugify?
Pass max_length together with word_boundary=True, for example slugify(title, max_length=60, word_boundary=True). Without word_boundary, the slug is cut at exactly max_length characters, which can split a word in half.