Regex Match but Exclude: Negative Lookahead for Words, Paths and Patterns
Regular expressions describe what to match, so "match everything except X" needs a different tool: a lookaround. A negative lookahead `(?!…)` asserts that something does not follow at the current position, and a negative lookbehind `(?<!…)` that something does not precede it. Neither consumes characters. That small fact explains both why they work and why most broken "exclude" patterns fail. Every pattern below was run against the sample input shown.
Exclude lines that contain a word
The most common task: match whole lines unless they contain a word. Anchor the lookahead at the start of the line and let it scan ahead with .*:
^(?!.*error).*$
"all good" -> match
"an error here" -> no match
Read it as: at the start of the line, it must not be possible to find error anywhere further on; then match the whole line. Without the .* inside the lookahead, ^(?!error) only checks the first characters, so it rejects "error at start" but accepts "an error here". Use the m flag to apply ^ and $ to each line of a multi-line string.
To exclude several words, put an alternation inside the lookahead, and add word boundaries if a partial match should not count:
^(?!.*\b(?:error|fatal|warn)\b).*$
"ok line" -> match
"fatal: x" -> no match
"warning only" -> match (\b stops "warn" matching inside "warning")
Match words except certain words
When you want individual words rather than lines, the lookahead goes at the start of each word, and it needs its own boundary at the end. Compare the naive and the correct version on the same text:
const text = "cat dog bird catalog dogma";
text.match(/\b(?!cat|dog)\w+/g); // [ 'bird' ] — catalog and dogma lost too
text.match(/\b(?!(?:cat|dog)\b)\w+/g); // [ 'bird', 'catalog', 'dogma' ]
The naive pattern excludes every word that starts with cat or dog. Putting \b inside the lookahead, after the group, means "not the whole word cat or dog", which is nearly always what was intended. The non-capturing group (?:…) matters too: without it, (?!cat|dog\b) would apply the boundary only to dog.
Exclude a URL path
Routing rules, redirects and analytics filters often need "every URL on this site except one section". The lookahead sits right after the fixed prefix:
^https?://example\.com/(?!admin(?:/|$))
https://example.com/blog/x -> match
https://example.com/admin/users -> no match
https://example.com/admin -> no match
https://example.com/administrators -> match (a different section)
The (?:/|$) after admin is what keeps /administrators in. Without it the lookahead blocks any path that begins with those five letters. Remember to escape the dots in the host name, otherwise example.com also matches exampleXcom. In nginx, Apache or Google Analytics, check which regex flavour the field uses before relying on lookarounds: RE2-based systems such as Google Analytics 4 and Go do not support them at all, and there you must express the rule with two conditions, include and exclude.
Exclude a pattern at the end: file names and extensions
The same anchored-lookahead idea works for suffixes. To match JavaScript files but not minified ones, check from the start that the string does not end in .min.js:
["app.js", "app.min.js", "vendor.min.js", "main.ts"]
.filter(f => /^(?!.*\.min\.js$).*\.js$/.test(f));
// [ 'app.js' ]
A negative lookbehind before the end can express the same thing, ^.*(?<!\.min)\.js$, but the lookahead form works in every flavour that supports lookarounds, while variable-length lookbehind does not, as the next section shows.
Exclude part of the match with lookbehind
Sometimes the question is not "exclude these strings" but "match the number, but not the currency sign before it". A positive lookbehind requires the context without including it in the result; a negative lookbehind forbids it:
"Total $42 and 17 items".match(/(?<=\$)\d+/g); // [ '42' ] only amounts after $
"Total $42 and 17 items".match(/(?<![$\d])\d+/g); // [ '17' ] numbers NOT after $
In the negative version, \d is in the class as well; otherwise the match could start one digit later, and 2 from $42 would slip through. JavaScript has supported lookbehind since ES2018, and it allows any length. Python's re rejects variable-length lookbehind with the error "look-behind requires fixed-width pattern". PHP and other PCRE flavours offer \K, which discards everything matched so far, as an alternative:
import re
re.findall(r"(?<=\$)\d+", "Total $42 and 17 items") # ['42']
// PHP: \K drops "$" from the reported match
preg_match_all('/\$\K\d+/', 'Total $42 and 17 items', $m); // $m[0] = ['42']
Exclude matches inside HTML tags or quotes
"Match example.com but not inside links" cannot be done reliably with lookarounds alone, because the excluded region has no fixed length. The robust regex technique is to match the unwanted context first and keep only the wanted alternative:
const html = '<p>Visit example.com or <a href="https://x">example.com</a> and example.com</p>';
html.replace(/<a\b[^>]*>.*?<\/a>|(example\.com)/g,
(m, wanted) => wanted ? `[${wanted}]` : m);
// '<p>Visit [example.com] or <a href="https://x">example.com</a> and [example.com]</p>'
The left alternative swallows whole anchor elements and is returned unchanged; only the captured group outside anchors is replaced. The same "skip what you do not want, capture what you do" idea handles strings inside quotes and code comments. For real HTML with nesting and attributes, parse it with a DOM parser and process text nodes instead.
Traps that make exclusions fail silently
An unanchored lookahead moves. /(?!foo)\w+/ on "foo bar" matches oo: at position 0 the lookahead fails, so the engine simply tries position 1, where oo bar does not start with foo. Always pin the lookahead with ^, \b or a fixed prefix.
Lookarounds do not consume, so (?!x) followed by nothing matches the empty string. "error".match(/^(?!error)/) returns null, but on any other input it returns an empty match, which many if (match) checks treat as success.
Character classes are not word lists. [^cat] means "one character that is not c, a or t", not "not the word cat". Test each pattern against real positive and negative samples in a regex tester before shipping it.
Sources and further reading
The ECMAScript specification (ES2018 and later) defines lookahead and lookbehind assertions in JavaScript, and MDN's "Lookahead assertion" and "Lookbehind assertion" pages document them with examples. The Python re module documentation describes (?!…), (?<!…) and the fixed-width lookbehind rule. The PCRE2 pattern documentation covers \K. Google's RE2 syntax page lists the lookarounds it does not support.
Frequently asked questions
How do I match a line that does not contain a word?
Use ^(?!.*word).*$ with the multiline flag. The lookahead at the start of the line checks that word appears nowhere further on, and .* then matches the whole line.
How do I exclude multiple words with negative lookahead?
Put an alternation inside the lookahead: ^(?!.*\b(?:one|two|three)\b).*$. The word boundaries stop partial matches, so excluding warn does not also exclude warning.
Why does my negative lookahead still match?
Usually it is not anchored. Without ^, \b or a fixed prefix, the engine retries the lookahead one character later and finds a position where it succeeds, so /(?!foo)\w+/ matches oo in foo.
How do I exclude a URL path with regex?
Place the lookahead right after the fixed part: ^https?://example\.com/(?!admin(?:/|$)). The (?:/|$) keeps longer paths such as /administrators matching while blocking /admin itself.
Can I exclude part of the match from the result?
Yes. A lookbehind such as (?<=\$)\d+ requires the $ but returns only the digits. In PHP and other PCRE flavours, \K after the prefix does the same, and it also works with variable-length prefixes.
Do all regex engines support negative lookahead?
No. JavaScript, Python, PHP, Java and .NET do, but RE2-based systems such as Go and Google Analytics 4 do not. There you have to split the rule into separate include and exclude conditions.