What a slug is, and what this pattern checks
A slug is the human-readable segment of a URL path that identifies one piece of content
— the my-great-post in example.com/blog/my-great-post.
Convention, not any standard, dictates its shape: lowercase, alphanumeric segments joined by
single hyphens, with no leading or trailing hyphen and no doubled hyphen. This pattern
validates that shape; it does not generate a slug from a
title, which is a separate, harder problem covered further down.
Token by token
| Token | Meaning |
|---|---|
^ | Start of the string |
[a-z0-9]+ | One or more lowercase letters or digits — the first segment |
(?:-[a-z0-9]+)* | Zero or more repetitions of: a single literal hyphen, then another run of one or more lowercase letters or digits |
$ | End of the string |
The group is non-capturing ((?:…), not (…))
because nothing here needs to read back which specific segment matched — it exists
purely to give * something to repeat: “hyphen, then more
characters”, as a unit, zero or more times. That single design choice is what makes
the whole shape work: a hyphen can only ever appear immediately followed by at least one
alphanumeric character, which is exactly what rules out a leading hyphen, a trailing one,
and a doubled one, all in one small pattern rather than three separate checks.
What it matches
hello-world— the ordinary case.abc123-def-456— digits mixed into any segment.a— a single character is a valid slug; there is no minimum length beyond “at least one character”.
What it deliberately does not match, and why
| Input | Verdict | Why |
|---|---|---|
Hello-World | Rejected | Uppercase letters — the character class is a-z only, deliberately, since slugs conventionally normalise case for consistent, case-insensitive-looking URLs |
hello--world | Rejected | A doubled hyphen — the non-capturing group requires at least one alphanumeric character between any two hyphens, so two hyphens in a row have nothing to separate |
-leading-hyphen | Rejected | The ^ anchor requires the string to start with [a-z0-9]+, not a hyphen |
trailing- | Rejected | Symmetric with the above: the $ anchor requires the string to end with an alphanumeric run, not a hyphen |
has spaces | Rejected | A space is not in the character class at all — spaces should already be converted to hyphens before slug validation, not after |
café | Rejected | An accented character outside a-z. See below for why transliterating it is a separate step from validating the result |
Validating a slug is not the same as generating one
This pattern answers “is this string already slug-shaped”. Getting from an
arbitrary title — My Great Post!, or Café Résumé, or a title written
entirely in a non-Latin script — to a slug that passes this pattern is a different
job, and a considerably harder one once non-ASCII text is involved. The mechanical part is
straightforward: lowercase the string, strip characters outside the allowed set, and collapse
runs of whitespace or removed characters into single hyphens. The judgement call is what
happens to a character like é or ü or a Cyrillic or CJK
character, since none of them fit [a-z0-9]. Two defensible strategies exist,
and they produce very different slugs from the same title:
- Transliterate: convert
étoe,ütou, and so on, using a mapping table or a library, soCafébecomescafe. This keeps the slug ASCII and this pattern happy, at the cost of losing the distinction between accented and unaccented forms. - Keep it Unicode: many modern platforms (including most blogging
software) allow slugs containing the original non-Latin letters, relying on the browser
and server to handle Unicode paths and percent-encoding correctly, rather than forcing
everything through an ASCII transliteration. A pattern permitting this needs a Unicode
property escape such as
\p{L}in place ofa-z, which requires theuflag and is governed by UTS #18’s rules for how a regex engine should classify Unicode letters — a materially different pattern from the ASCII-only one on this page, and one that then has to define its own rules for what “lowercase” means across scripts that do not have letter case at all (most CJK scripts, for instance).
This page’s pattern deliberately takes the first, ASCII-only stance, because it is the more common convention on the open web and the simpler one to reason about; if your platform has chosen the second, you need a different pattern than the one above, not a variation of it.
Flavour: this is JavaScript’s regex
This pattern targets JavaScript’s built-in
RegExp,
per ECMA-262.
It uses only literals, a character class, +, * and a non-capturing
group — every one of those is identical in PCRE and Python’s re, so
this exact text ports unchanged. The Unicode variant discussed above, using
\p{L}, needs the u flag in JavaScript specifically;
PCRE spells the equivalent property class similarly but with its own flag conventions. See
the cookbook’s flavour-honesty section for the rest of the
differences.
Worked examples
1. A minimal ASCII slugify function
function slugify(title) {
return title
.toLowerCase()
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '') // strip accent marks: é -> e
.replace(/[^a-z0-9]+/g, '-') // anything else becomes a hyphen
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
slugify('Café Résumé: My Great Post!'); // -> "cafe-resume-my-great-post"
Run the output through this page’s pattern as a final assertion before storing it — the slugify step above is the generator, the regex on this page is the validator, and keeping them separate means a bug in one does not silently corrupt the other’s guarantee.
2. Enforcing uniqueness is not this pattern’s job either
Two different posts can both slugify to my-great-post. This pattern has no
way to know that, since it validates one string in isolation. Uniqueness needs a database
constraint or a lookup against existing slugs, with a numeric or dated suffix
(my-great-post-2) appended on collision — itself still a valid slug
under this same pattern.
3. A slug inside a full URL
This pattern validates one path segment in isolation, not a whole URL. If you need to
confirm a full link is well-formed and then pull just the last segment out to validate as
a slug, see the dedicated URL page for the broader-shaped pattern and
why parsing the path with the URL constructor beats extending either
regex to do both jobs at once.
4. Rejecting a slug that collides with a reserved route
A slug of admin, api, or new is perfectly valid
against this pattern, but any of them may collide with a fixed route your application
already defines — /blog/new meaning “create a post” rather
than a post titled “new”. This pattern has no way to know your route table, and
should not be extended to try — keep an explicit denylist of reserved slugs in your
own routing code and check it separately, after this pattern has already confirmed the
shape is otherwise valid.
Using this pattern in your own code
The pattern uses only a character class, +, * and a
non-capturing group — all standard, so the text carries over unchanged.
// JavaScript
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
SLUG_RE.test('hello-world'); // true
SLUG_RE.test('Hello-World'); // false
# Python's re module — identical pattern text
import re
SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
bool(SLUG_RE.match('abc123-def-456')) # True
# grep -P, checking every generated slug in a CSV export in one pass
grep -Pv '^[a-z0-9]+(?:-[a-z0-9]+)*$' slugs.csv # prints every INVALID row
The last example flips the usual sense with grep’s -v flag
deliberately: for an audit of already-generated slugs, the useful output is the list of
rows that fail the pattern, not the (presumably much longer) list that pass it.
Common mistakes when adapting this pattern
- Making the whole pattern one character class instead of a repeated
group.
^[a-z0-9-]+$looks equivalent at a glance but is not: it happily accepts-leading,trailing-anddouble--hyphen, because a bare hyphen is just another allowed character with no positional constraint. The non-capturing-group structure on this page ([a-z0-9]+(?:-[a-z0-9]+)*) is what actually rules those three cases out, by requiring a hyphen to always sit between two alphanumeric runs. - Allowing underscores “to be safe”. Adding
_to the character class acceptsmy_postalongsidemy-post, which then means two different-looking URLs can point at conceptually the same slug depending on which one a title was slugified into — a consistency problem more than a validity one. Most platforms standardise on hyphens only and convert underscores to hyphens during generation, rather than accepting both at validation time. - Validating before slugifying instead of after. Running this pattern against a raw, unprocessed title will reject almost everything, since real titles contain spaces, punctuation and capital letters by default. This pattern is the last step, checking the output of a slugify function (worked example 1 above), not a filter applied to the raw input a person actually types.
Frequently asked questions
Should slugs allow uppercase letters?
Convention says no. Two URLs differing only in case
(/Post versus /post) invite duplicate-content confusion for
search engines and case-sensitive-server mismatches; lowercasing everything sidesteps
both. Some frameworks case-fold URLs automatically, which only reinforces treating slugs
as lowercase-only from the start.
Can a slug be all digits, like 12345?
Yes, this pattern accepts it — [a-z0-9]+ allows
digits with no letters at all. Whether that is a good idea for your application (it can be
confused with a numeric database ID in some routing schemes) is a separate design
decision this pattern does not make for you.
Does this pattern support Unicode slugs?
No, by design — see “validating a slug is not the same
as generating one” above for the ASCII-versus-Unicode trade-off and the
\p{L} alternative if your platform allows non-Latin slugs.
What is the maximum slug length this pattern allows?
None — + and * are unbounded. If
you need a length cap (many platforms cap slugs around 100–200 characters for
practical URL-length and readability reasons), enforce it separately, either with
{1,80}-style bounded quantifiers in place of +/*, or
a plain length check on the string before or after the regex.
Why does a-b-c pass but a--b fail?
Because every hyphen in a-b-c is immediately followed
by at least one alphanumeric character, satisfying (?:-[a-z0-9]+)* for each
one. In a--b, the second hyphen is immediately followed by another hyphen,
not a letter or digit, so that repetition of the group cannot match — there is
nothing for [a-z0-9]+ to consume right after it.
Should I validate on every keystroke while someone edits a slug field?
Usually not directly against this exact pattern — a slug being typed is invalid at almost every intermediate stage (a single trailing hyphen while someone is about to type the next word, for instance). Auto-generating the slug from a title field as the person types, using a function like the one in worked example 1, and validating only the final result, is the friendlier pattern in practice.
Can a slug be purely numeric, and is that a problem later?
This pattern accepts 12345 as a valid slug, since
[a-z0-9]+ has no requirement that a letter appear anywhere. It can become a
problem if your routing also has numeric IDs in the same position — a request for
/posts/12345 could then be ambiguous between “post with slug
12345” and “post with ID 12345”. If your application distinguishes the
two, resolve that ambiguity in your routing logic explicitly, rather than trying to teach
this pattern to reject all-numeric strings, which would also break any post genuinely
titled entirely in numbers.
Is my draft title or slug sent anywhere when I test it here?
No. Matching runs inside a same-origin Web Worker, and there is no upload endpoint anywhere on this page — an unpublished title never leaves your browser.
Limitations
- ASCII lowercase and digits only; no built-in Unicode support (see the section
above for the trade-off and the
\p{L}alternative). - Validates shape only — cannot detect a duplicate slug, which needs a database or lookup check.
- No length limit; add a bounded quantifier or separate length check if your platform needs one.
- Does not generate a slug from a title; pair with a transliteration/normalisation step like worked example 1 for that half of the job.
Related
- Regex to match a URLThe whole link a slug is usually just one segment of
- Regex for an ISO 8601 dateAnother commonly-used building block in dated slugs like 2026-07-22-my-post
- Regex for email validationThe same “shape, not proof” honesty, applied to addresses
- About this toolWhy it exists and how the tester is built
- JSON formatterAnother text tool that runs entirely in your browser
- All toolsSee everything on this site