Every CMS, static site generator and web framework needs a slugify function, yet most rely on a naive regex that breaks on accented titles. This guide explains a slugify regex approach that works for real-world text, then provides tested slugify JavaScript, Python, PHP, Go and SQL functions. All of them turn Crème Brûlée & Café: 50% Off! into creme-brulee-and-cafe-50-percent-off without corrupting letters or splitting words.
The slugify recipe in 6 steps
- 01Replace symbols that carry meaning with words, for example
&with "and" and%with "percent", before they are stripped. - 02Spell out letters that do not decompose
ß,æ,œ,ø,đandłhave no combining accent, so map them toss,ae,oe,o,dandlby hand. - 03Normalize to NFD and remove combining marksUnicode NFD splits
éintoeplus an accent, and the regex\p{M}(or the combining-marks range[\u0300-\u036f]) removes the accent. - 04Lowercase and drop apostrophes, so "don't" becomes "dont" rather than "don-t".
- 05Replace every run of non-alphanumeric characters with one hyphen using
[^a-z0-9]+. This collapses spaces, punctuation and emoji in one pass. - 06Trim hyphens from both ends and, if you need a length limit, cut at the last hyphen before the limit so no word is split.
JavaScript / TypeScript
This version uses String.prototype.normalize and Unicode property escapes, which work in all modern browsers, Node.js, Deno and Bun. The CHAR_MAP covers letters that do not decompose plus Cyrillic and Greek romanization. It also works in TypeScript.
// slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// Works in modern browsers, Node.js 16+, Deno and Bun. No dependencies.
const CHAR_MAP = {"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"};
const SYMBOLS = [["&","and"],["@","at"],["%","percent"],["+","plus"],["€","euro"],["$","dollar"],["£","pound"]];
const STOP_WORDS = new Set([]);
const SEPARATOR = "-";
const MAX_WORDS = 0;
const MAX_CHARS = 0;
function mapChars(s) {
let out = '';
for (const ch of s) out += Object.prototype.hasOwnProperty.call(CHAR_MAP, ch) ? CHAR_MAP[ch] : ch;
return out;
}
function slugify(input) {
let s = String(input ?? '').normalize('NFC');
for (const [k, v] of SYMBOLS) s = s.split(k).join(' ' + v + ' ');
s = s.replace(/[\uFE00-\uFE0F\u200D\u20E3]/gu, '').replace(/['\u2019\u02BC`]/gu, '');
s = mapChars(mapChars(s).normalize('NFD').replace(/\p{M}/gu, ''));
s = s.replace(/[^A-Za-z0-9]+/g, ' ');
let tokens = s.split(' ').filter(Boolean);
tokens = tokens.map((t) => t.toLowerCase());
if (MAX_WORDS > 0) tokens = tokens.slice(0, MAX_WORDS);
if (MAX_CHARS > 0 && tokens.length) {
const out = [tokens[0]];
let len = [...tokens[0]].length;
for (let i = 1; i < tokens.length; i++) {
const add = [...SEPARATOR].length + [...tokens[i]].length;
if (len + add > MAX_CHARS) break; // stop at a word boundary
out.push(tokens[i]);
len += add;
}
tokens = out;
}
return tokens.join(SEPARATOR);
}
// Example: slugify("Crème Brûlée & Café")
// export { slugify }; // uncomment when using ES modulesPython
Python's standard library includes unicodedata, so no package is needed. The function uses the same character map and symbol list as the JavaScript version and returns identical slugs.
# slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
# Python 3.8+, standard library only.
import re
import unicodedata
CHAR_MAP = {"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"}
SYMBOLS = [("&", "and"), ("@", "at"), ("%", "percent"), ("+", "plus"), ("€", "euro"), ("$", "dollar"), ("£", "pound")]
STOP_WORDS = set()
SEPARATOR = "-"
MAX_WORDS = 0
MAX_CHARS = 0
def _map_chars(s):
return ''.join(CHAR_MAP.get(ch, ch) for ch in s)
def slugify(text):
s = unicodedata.normalize('NFC', str(text))
for k, v in SYMBOLS:
s = s.replace(k, ' ' + v + ' ')
s = re.sub('[\ufe00-\ufe0f\u200d\u20e3]', '', s)
s = re.sub("['\u2019\u02bc`]", '', s)
s = unicodedata.normalize('NFD', _map_chars(s))
s = _map_chars(''.join(ch for ch in s if not unicodedata.category(ch).startswith('M')))
s = re.sub(r'[^A-Za-z0-9]+', ' ', s)
tokens = [t for t in s.split(' ') if t]
tokens = [t.lower() for t in tokens]
if MAX_WORDS > 0:
tokens = tokens[:MAX_WORDS]
if MAX_CHARS > 0 and tokens:
out, length = [tokens[0]], len(tokens[0])
for t in tokens[1:]:
add = len(SEPARATOR) + len(t)
if length + add > MAX_CHARS:
break # stop at a word boundary
out.append(t)
length += add
tokens = out
return SEPARATOR.join(tokens)
if __name__ == '__main__':
print(slugify("Crème Brûlée & Café"))PHP
PHP needs the mbstring extension for multibyte lowercase and the intl extension for Normalizer::normalize(). Both ship with most PHP builds; check with php -m.
<?php
// slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// PHP 7.4+ with the mbstring and intl extensions.
function slugify(string $text): string
{
static $charMap = [
'ß' => 'ss', 'ẞ' => 'SS', 'æ' => 'ae', 'Æ' => 'AE', 'œ' => 'oe', 'Œ' => 'OE',
'ø' => 'o', 'Ø' => 'O', 'đ' => 'd', 'Đ' => 'D', 'ð' => 'd', 'Ð' => 'D',
'þ' => 'th', 'Þ' => 'TH', 'ł' => 'l', 'Ł' => 'L', 'ı' => 'i', 'ħ' => 'h',
'Ħ' => 'H', 'ŧ' => 't', 'Ŧ' => 'T', 'ŀ' => 'l', 'Ŀ' => 'L', 'ŋ' => 'n',
'Ŋ' => 'N', 'ſ' => 's', 'ĸ' => 'k', 'ƒ' => 'f', 'а' => 'a', 'А' => 'A',
'б' => 'b', 'Б' => 'B', 'в' => 'v', 'В' => 'V', 'г' => 'g', 'Г' => 'G',
'д' => 'd', 'Д' => 'D', 'е' => 'e', 'Е' => 'E', 'ё' => 'yo', 'Ё' => 'Yo',
'ж' => 'zh', 'Ж' => 'Zh', 'з' => 'z', 'З' => 'Z', 'и' => 'i', 'И' => 'I',
'й' => 'y', 'Й' => 'Y', 'к' => 'k', 'К' => 'K', 'л' => 'l', 'Л' => 'L',
'м' => 'm', 'М' => 'M', 'н' => 'n', 'Н' => 'N', 'о' => 'o', 'О' => 'O',
'п' => 'p', 'П' => 'P', 'р' => 'r', 'Р' => 'R', 'с' => 's', 'С' => 'S',
'т' => 't', 'Т' => 'T', 'у' => 'u', 'У' => 'U', 'ф' => 'f', 'Ф' => 'F',
'х' => 'kh', 'Х' => 'Kh', 'ц' => 'ts', 'Ц' => 'Ts', 'ч' => 'ch', 'Ч' => 'Ch',
'ш' => 'sh', 'Ш' => 'Sh', 'щ' => 'shch', 'Щ' => 'Shch', 'ъ' => '', 'Ъ' => '',
'ы' => 'y', 'Ы' => 'Y', 'ь' => '', 'Ь' => '', 'э' => 'e', 'Э' => 'E',
'ю' => 'yu', 'Ю' => 'Yu', 'я' => 'ya', 'Я' => 'Ya', 'є' => 'ye', 'Є' => 'Ye',
'і' => 'i', 'І' => 'I', 'ї' => 'yi', 'Ї' => 'Yi', 'ґ' => 'g', 'Ґ' => 'G',
'ў' => 'u', 'Ў' => 'U', 'ђ' => 'dj', 'Ђ' => 'Dj', 'ј' => 'j', 'Ј' => 'J',
'љ' => 'lj', 'Љ' => 'Lj', 'њ' => 'nj', 'Њ' => 'Nj', 'ћ' => 'c', 'Ћ' => 'C',
'џ' => 'dz', 'Џ' => 'Dz', 'ѓ' => 'gj', 'Ѓ' => 'Gj', 'ќ' => 'kj', 'Ќ' => 'Kj',
'ѕ' => 'dz', 'Ѕ' => 'Dz', 'α' => 'a', 'Α' => 'A', 'β' => 'v', 'Β' => 'V',
'γ' => 'g', 'Γ' => 'G', 'δ' => 'd', 'Δ' => 'D', 'ε' => 'e', 'Ε' => 'E',
'ζ' => 'z', 'Ζ' => 'Z', 'η' => 'i', 'Η' => 'I', 'θ' => 'th', 'Θ' => 'Th',
'ι' => 'i', 'Ι' => 'I', 'κ' => 'k', 'Κ' => 'K', 'λ' => 'l', 'Λ' => 'L',
'μ' => 'm', 'Μ' => 'M', 'ν' => 'n', 'Ν' => 'N', 'ξ' => 'x', 'Ξ' => 'X',
'ο' => 'o', 'Ο' => 'O', 'π' => 'p', 'Π' => 'P', 'ρ' => 'r', 'Ρ' => 'R',
'σ' => 's', 'Σ' => 'S', 'ς' => 's', 'τ' => 't', 'Τ' => 'T', 'υ' => 'y',
'Υ' => 'Y', 'φ' => 'f', 'Φ' => 'F', 'χ' => 'ch', 'Χ' => 'Ch', 'ψ' => 'ps',
'Ψ' => 'Ps', 'ω' => 'o', 'Ω' => 'O',
];
$symbols = [['&', 'and'], ['@', 'at'], ['%', 'percent'], ['+', 'plus'], ['€', 'euro'], ['$', 'dollar'], ['£', 'pound']];
$stopWords = [];
$separator = '-';
$maxWords = 0;
$maxChars = 0;
if (!class_exists('Normalizer')) {
throw new RuntimeException('slugify() needs the PHP intl extension (Normalizer).');
}
$s = Normalizer::normalize($text, Normalizer::FORM_C);
foreach ($symbols as [$k, $v]) {
$s = str_replace($k, ' ' . $v . ' ', $s);
}
$s = preg_replace('/[\x{FE00}-\x{FE0F}\x{200D}\x{20E3}]/u', '', $s);
$s = preg_replace('/[\'\x{2019}\x{02BC}`]/u', '', $s);
$s = Normalizer::normalize(strtr($s, $charMap), Normalizer::FORM_D);
$s = strtr(preg_replace('/\p{M}/u', '', $s), $charMap);
$s = preg_replace('/[^A-Za-z0-9]+/', ' ', $s);
$tokens = array_values(array_filter(explode(' ', $s), 'strlen'));
$tokens = array_map(function ($t) { return mb_strtolower($t, 'UTF-8'); }, $tokens);
if ($maxWords > 0) {
$tokens = array_slice($tokens, 0, $maxWords);
}
if ($maxChars > 0 && $tokens) {
$out = [$tokens[0]];
$len = mb_strlen($tokens[0], 'UTF-8');
for ($i = 1; $i < count($tokens); $i++) {
$add = mb_strlen($separator, 'UTF-8') + mb_strlen($tokens[$i], 'UTF-8');
if ($len + $add > $maxChars) {
break; // stop at a word boundary
}
$out[] = $tokens[$i];
$len += $add;
}
$tokens = $out;
}
return implode($separator, $tokens);
}
// echo slugify('Crème Brûlée & Café');Go
Go's standard library has no Unicode normalizer, so this function uses the golang.org/x/text module (go get golang.org/x/text). It applies the same character map, symbol words, NFD mark removal and word-boundary length limit as the JavaScript version, and returns identical slugs.
// Package slug: Slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// Go 1.18+. Needs golang.org/x/text for Unicode normalization:
// go get golang.org/x/text
package slug
import (
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
var charMap = map[rune]string{
'ß': "ss", 'ẞ': "SS", 'æ': "ae", 'Æ': "AE", 'œ': "oe", 'Œ': "OE",
'ø': "o", 'Ø': "O", 'đ': "d", 'Đ': "D", 'ð': "d", 'Ð': "D",
'þ': "th", 'Þ': "TH", 'ł': "l", 'Ł': "L", 'ı': "i", 'ħ': "h",
'Ħ': "H", 'ŧ': "t", 'Ŧ': "T", 'ŀ': "l", 'Ŀ': "L", 'ŋ': "n",
'Ŋ': "N", 'ſ': "s", 'ĸ': "k", 'ƒ': "f", 'а': "a", 'А': "A",
'б': "b", 'Б': "B", 'в': "v", 'В': "V", 'г': "g", 'Г': "G",
'д': "d", 'Д': "D", 'е': "e", 'Е': "E", 'ё': "yo", 'Ё': "Yo",
'ж': "zh", 'Ж': "Zh", 'з': "z", 'З': "Z", 'и': "i", 'И': "I",
'й': "y", 'Й': "Y", 'к': "k", 'К': "K", 'л': "l", 'Л': "L",
'м': "m", 'М': "M", 'н': "n", 'Н': "N", 'о': "o", 'О': "O",
'п': "p", 'П': "P", 'р': "r", 'Р': "R", 'с': "s", 'С': "S",
'т': "t", 'Т': "T", 'у': "u", 'У': "U", 'ф': "f", 'Ф': "F",
'х': "kh", 'Х': "Kh", 'ц': "ts", 'Ц': "Ts", 'ч': "ch", 'Ч': "Ch",
'ш': "sh", 'Ш': "Sh", 'щ': "shch", 'Щ': "Shch", 'ъ': "", 'Ъ': "",
'ы': "y", 'Ы': "Y", 'ь': "", 'Ь': "", 'э': "e", 'Э': "E",
'ю': "yu", 'Ю': "Yu", 'я': "ya", 'Я': "Ya", 'є': "ye", 'Є': "Ye",
'і': "i", 'І': "I", 'ї': "yi", 'Ї': "Yi", 'ґ': "g", 'Ґ': "G",
'ў': "u", 'Ў': "U", 'ђ': "dj", 'Ђ': "Dj", 'ј': "j", 'Ј': "J",
'љ': "lj", 'Љ': "Lj", 'њ': "nj", 'Њ': "Nj", 'ћ': "c", 'Ћ': "C",
'џ': "dz", 'Џ': "Dz", 'ѓ': "gj", 'Ѓ': "Gj", 'ќ': "kj", 'Ќ': "Kj",
'ѕ': "dz", 'Ѕ': "Dz", 'α': "a", 'Α': "A", 'β': "v", 'Β': "V",
'γ': "g", 'Γ': "G", 'δ': "d", 'Δ': "D", 'ε': "e", 'Ε': "E",
'ζ': "z", 'Ζ': "Z", 'η': "i", 'Η': "I", 'θ': "th", 'Θ': "Th",
'ι': "i", 'Ι': "I", 'κ': "k", 'Κ': "K", 'λ': "l", 'Λ': "L",
'μ': "m", 'Μ': "M", 'ν': "n", 'Ν': "N", 'ξ': "x", 'Ξ': "X",
'ο': "o", 'Ο': "O", 'π': "p", 'Π': "P", 'ρ': "r", 'Ρ': "R",
'σ': "s", 'Σ': "S", 'ς': "s", 'τ': "t", 'Τ': "T", 'υ': "y",
'Υ': "Y", 'φ': "f", 'Φ': "F", 'χ': "ch", 'Χ': "Ch", 'ψ': "ps",
'Ψ': "Ps", 'ω': "o", 'Ω': "O",
}
// Replaced in this order (longest symbol first).
var symbols = [][2]string{{"&", "and"}, {"@", "at"}, {"%", "percent"}, {"+", "plus"}, {"€", "euro"}, {"$", "dollar"}, {"£", "pound"}}
var stopWords = map[string]bool{}
const (
separator = "-"
maxWords = 0
maxChars = 0
)
var (
reJoiners = regexp.MustCompile(`[\x{FE00}-\x{FE0F}\x{200D}\x{20E3}]`)
reApostrophe = regexp.MustCompile("['\u2019\u02BC`]")
reMarks = regexp.MustCompile(`\p{M}`)
reNonAlnum = regexp.MustCompile(`[^A-Za-z0-9]+`)
)
func mapChars(s string) string {
var b strings.Builder
for _, r := range s {
if v, ok := charMap[r]; ok {
b.WriteString(v)
} else {
b.WriteRune(r)
}
}
return b.String()
}
// Slugify turns a title into a URL slug.
func Slugify(input string) string {
s := norm.NFC.String(input)
for _, kv := range symbols {
s = strings.ReplaceAll(s, kv[0], " "+kv[1]+" ")
}
s = reApostrophe.ReplaceAllString(reJoiners.ReplaceAllString(s, ""), "")
s = mapChars(reMarks.ReplaceAllString(norm.NFD.String(mapChars(s)), ""))
s = reNonAlnum.ReplaceAllString(s, " ")
var tokens []string
for _, t := range strings.Split(s, " ") {
t = strings.ToLower(t)
if t != "" {
tokens = append(tokens, t)
}
}
if maxWords > 0 && len(tokens) > maxWords {
tokens = tokens[:maxWords]
}
if maxChars > 0 && len(tokens) > 0 {
out := []string{tokens[0]}
n := utf8.RuneCountInString(tokens[0])
for _, t := range tokens[1:] {
add := utf8.RuneCountInString(separator) + utf8.RuneCountInString(t)
if n+add > maxChars {
break // stop at a word boundary
}
out = append(out, t)
n += add
}
tokens = out
}
return strings.Join(tokens, separator)
}
// Example: slug.Slugify("Crème Brûlée & Café")SQL (PostgreSQL)
This PL/pgSQL function needs PostgreSQL 13 or later (for normalize()) and a UTF-8 database, and no extensions. It carries the same character map and symbol words as the JavaScript version and lists the Unicode combining-mark ranges explicitly, because PostgreSQL regular expressions do not support \p{M}. It is marked IMMUTABLE, so you can use it in an index or a generated column.
-- slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
-- PostgreSQL 13+ with a UTF-8 database. No extensions needed.
-- Usage: SELECT slugify(title) FROM posts;
CREATE OR REPLACE FUNCTION slugify(input text)
RETURNS text
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
AS $slugify$
DECLARE
char_map jsonb := '{"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"}';
marks text := '\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1-\u05C2\u05C4-\u05C5\u05C7\u0610-\u061A'
|| '\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7-\u06E8\u06EA-\u06ED\u0711\u0730-\u074A'
|| '\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D'
|| '\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957'
|| '\u0962-\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7-\u09C8\u09CB-\u09CD\u09D7\u09E2-\u09E3\u09FE'
|| '\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47-\u0A48\u0A4B-\u0A4D\u0A51\u0A70-\u0A71\u0A75\u0A81-\u0A83'
|| '\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2-\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C'
|| '\u0B3E-\u0B44\u0B47-\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B62-\u0B63\u0B82\u0BBE-\u0BC2'
|| '\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3C\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D'
|| '\u0C55-\u0C56\u0C62-\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD'
|| '\u0CD5-\u0CD6\u0CE2-\u0CE3\u0CF3\u0D00-\u0D03\u0D3B-\u0D3C\u0D3E-\u0D44\u0D46-\u0D48'
|| '\u0D4A-\u0D4D\u0D57\u0D62-\u0D63\u0D81-\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2-\u0DF3'
|| '\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18-\u0F19\u0F35\u0F37\u0F39'
|| '\u0F3E-\u0F3F\u0F71-\u0F84\u0F86-\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E'
|| '\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F'
|| '\u109A-\u109D\u135D-\u135F\u1712-\u1715\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17B4-\u17D3'
|| '\u17DD\u180B-\u180D\u180F\u1885-\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E'
|| '\u1A60-\u1A7C\u1A7F\u1AB0-\u1ACE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82'
|| '\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF4\u1CF7-\u1CF9'
|| '\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099-\u309A'
|| '\uA66F-\uA672\uA674-\uA67D\uA69E-\uA69F\uA6F0-\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA82C'
|| '\uA880-\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983'
|| '\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C-\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7-\uAAB8'
|| '\uAABE-\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5-\uAAF6\uABE3-\uABEA\uABEC-\uABED\uFB1E\uFE00-\uFE0F'
|| '\uFE20-\uFE2F\U000101FD\U000102E0\U00010376-\U0001037A\U00010A01-\U00010A03\U00010A05-\U00010A06'
|| '\U00010A0C-\U00010A0F\U00010A38-\U00010A3A\U00010A3F\U00010AE5-\U00010AE6\U00010D24-\U00010D27'
|| '\U00010D69-\U00010D6D\U00010EAB-\U00010EAC\U00010EFC-\U00010EFF\U00010F46-\U00010F50'
|| '\U00010F82-\U00010F85\U00011000-\U00011002\U00011038-\U00011046\U00011070\U00011073-\U00011074'
|| '\U0001107F-\U00011082\U000110B0-\U000110BA\U000110C2\U00011100-\U00011102\U00011127-\U00011134'
|| '\U00011145-\U00011146\U00011173\U00011180-\U00011182\U000111B3-\U000111C0\U000111C9-\U000111CC'
|| '\U000111CE-\U000111CF\U0001122C-\U00011237\U0001123E\U00011241\U000112DF-\U000112EA'
|| '\U00011300-\U00011303\U0001133B-\U0001133C\U0001133E-\U00011344\U00011347-\U00011348'
|| '\U0001134B-\U0001134D\U00011357\U00011362-\U00011363\U00011366-\U0001136C\U00011370-\U00011374'
|| '\U000113B8-\U000113C0\U000113C2\U000113C5\U000113C7-\U000113CA\U000113CC-\U000113D0\U000113D2'
|| '\U000113E1-\U000113E2\U00011435-\U00011446\U0001145E\U000114B0-\U000114C3\U000115AF-\U000115B5'
|| '\U000115B8-\U000115C0\U000115DC-\U000115DD\U00011630-\U00011640\U000116AB-\U000116B7'
|| '\U0001171D-\U0001172B\U0001182C-\U0001183A\U00011930-\U00011935\U00011937-\U00011938'
|| '\U0001193B-\U0001193E\U00011940\U00011942-\U00011943\U000119D1-\U000119D7\U000119DA-\U000119E0'
|| '\U000119E4\U00011A01-\U00011A0A\U00011A33-\U00011A39\U00011A3B-\U00011A3E\U00011A47'
|| '\U00011A51-\U00011A5B\U00011A8A-\U00011A99\U00011C2F-\U00011C36\U00011C38-\U00011C3F'
|| '\U00011C92-\U00011CA7\U00011CA9-\U00011CB6\U00011D31-\U00011D36\U00011D3A\U00011D3C-\U00011D3D'
|| '\U00011D3F-\U00011D45\U00011D47\U00011D8A-\U00011D8E\U00011D90-\U00011D91\U00011D93-\U00011D97'
|| '\U00011EF3-\U00011EF6\U00011F00-\U00011F01\U00011F03\U00011F34-\U00011F3A\U00011F3E-\U00011F42'
|| '\U00011F5A\U00013440\U00013447-\U00013455\U0001611E-\U0001612F\U00016AF0-\U00016AF4'
|| '\U00016B30-\U00016B36\U00016F4F\U00016F51-\U00016F87\U00016F8F-\U00016F92\U00016FE4'
|| '\U00016FF0-\U00016FF1\U0001BC9D-\U0001BC9E\U0001CF00-\U0001CF2D\U0001CF30-\U0001CF46'
|| '\U0001D165-\U0001D169\U0001D16D-\U0001D172\U0001D17B-\U0001D182\U0001D185-\U0001D18B'
|| '\U0001D1AA-\U0001D1AD\U0001D242-\U0001D244\U0001DA00-\U0001DA36\U0001DA3B-\U0001DA6C\U0001DA75'
|| '\U0001DA84\U0001DA9B-\U0001DA9F\U0001DAA1-\U0001DAAF\U0001E000-\U0001E006\U0001E008-\U0001E018'
|| '\U0001E01B-\U0001E021\U0001E023-\U0001E024\U0001E026-\U0001E02A\U0001E08F\U0001E130-\U0001E136'
|| '\U0001E2AE\U0001E2EC-\U0001E2EF\U0001E4EC-\U0001E4EF\U0001E5EE-\U0001E5EF\U0001E8D0-\U0001E8D6'
|| '\U0001E944-\U0001E94A\U000E0100-\U000E01EF';
stop_words text[] := ARRAY[]::text[];
separator text := '-';
max_words int := 0;
max_chars int := 0;
s text;
tokens text[];
kept text[];
len int;
BEGIN
s := normalize(input, NFC);
s := replace(s, '&', ' and ');
s := replace(s, '@', ' at ');
s := replace(s, '%', ' percent ');
s := replace(s, '+', ' plus ');
s := replace(s, '€', ' euro ');
s := replace(s, '$', ' dollar ');
s := replace(s, '£', ' pound ');
s := regexp_replace(s, '[\uFE00-\uFE0F\u200D\u20E3]', '', 'g');
s := regexp_replace(s, '[''\u2019\u02BC`]', '', 'g');
SELECT coalesce(string_agg(coalesce(char_map ->> c, c), '' ORDER BY n), '') INTO s
FROM regexp_split_to_table(s, '') WITH ORDINALITY AS x(c, n);
s := regexp_replace(normalize(s, NFD), '[' || marks || ']', '', 'g');
SELECT coalesce(string_agg(coalesce(char_map ->> c, c), '' ORDER BY n), '') INTO s
FROM regexp_split_to_table(s, '') WITH ORDINALITY AS x(c, n);
s := regexp_replace(s, '[^A-Za-z0-9]+', ' ', 'g');
tokens := array_remove(string_to_array(s, ' '), '');
tokens := ARRAY(SELECT lower(t) FROM unnest(tokens) WITH ORDINALITY AS u(t, n) ORDER BY n);
IF max_words > 0 THEN
tokens := tokens[1:max_words];
END IF;
IF max_chars > 0 AND cardinality(tokens) > 0 THEN
kept := ARRAY[tokens[1]];
len := char_length(tokens[1]);
FOR i IN 2..cardinality(tokens) LOOP
EXIT WHEN len + char_length(separator) + char_length(tokens[i]) > max_chars; -- word boundary
kept := kept || tokens[i];
len := len + char_length(separator) + char_length(tokens[i]);
END LOOP;
tokens := kept;
END IF;
RETURN coalesce(array_to_string(tokens, separator), '');
END;
$slugify$;
-- SELECT slugify('Crème Brûlée & Café');Warning
Common pitfalls
- Skipping normalization. A regex like
[^a-z0-9]+alone turnscaféintocaf, silently losing letters. - Forgetting letters that do not decompose. NFD does not change
ßorø, so they disappear unless you map them first. The remove accents tool uses the same table, so you can test a list of names before writing code. - Turning apostrophes into hyphens. Without a separate rule, "don't" becomes
don-t. Remove apostrophes, including the curly’, before the main regex. - Emoji and symbols. Emoji should be removed, but symbols like
&and%often carry meaning, so replace them with words first. - Cutting in the middle of a word.
slice(0, 50)can leave half a word. Cut at the last separator before the limit. - Assuming slugs are unique. Two titles can produce the same slug. Add a unique index in the database and append
-2,-3when a slug is taken. For existing spreadsheets or site migrations, our bulk CSV slug generator handles duplicate resolution automatically.
Generate a snippet for your exact rules
The functions above use one fixed set of rules. If you need different settings, such as underscores, German umlauts (ä→ae), Cyrillic and Greek romanization, custom stop words or a word limit, set them in our URL slug generator and open the "Get code snippet" panel. It generates a JavaScript, Python, PHP, Go or PostgreSQL slugify function with your exact settings built in, returning identical slugs for every input. For background on what makes a good slug, read what is a URL slug.
FAQ
Frequently asked questions
Why does my regex turn café into caf?
[^a-z0-9]+ removes é before it can become e. Normalize to NFD and remove combining marks first.
Do all the snippets return the same slug?
Yes. They share the same character map and symbol words, and the site's tests run the JavaScript, Python, PHP, Go and SQL versions on the same inputs and compare the output.
Does the TypeScript version need changes?
No. The JavaScript function runs in TypeScript as is.
Can the snippets convert Chinese to pinyin?
No. Chinese → pinyin runs only in the tools on this site, because it needs a pinyin dictionary that the snippets do not include. The functions on this page use the default settings (Smart transliteration on), so they remove Chinese characters and an all-Chinese title returns an empty slug. For pinyin slugs, convert the titles in the URL slug generator or the bulk CSV tool and copy the result. To keep Chinese, turn Smart transliteration off (Keep Unicode) in the URL slug generator and copy the code from its Get code snippet panel: the JavaScript/TypeScript, Python, PHP, Go and SQL versions then keep Chinese characters and only turn spaces and punctuation into hyphens (如何生成 URL Slug → 如何生成-url-slug).
