ToolKitSphere IconToolKitSphere
Developer Utilities

Regex Lookahead and Lookbehind Made Simple

Online Tools Platform Team7 min read

Most regex constructs match text and move on. Lookarounds do something different: they check a condition at the current position and then step back, leaving the cursor exactly where it was. That "check but don't consume" behaviour is what makes them awkward to picture and, once it clicks, the tool you reach for whenever a match depends on context rather than content. This post covers all four forms with examples you can paste and run. If you are still building the basics, Regex for Beginners: Your First Ten Patterns is the better starting point, and The Complete Guide to Regular Expressions has the full syntax map.

The Four Assertions

Syntax Name Succeeds when
(?=...) positive lookahead the pattern matches immediately after the cursor
(?!...) negative lookahead the pattern does not match after the cursor
(?<=...) positive lookbehind the pattern matches immediately before the cursor
(?<!...) negative lookbehind the pattern does not match before the cursor

All four are zero-width. They consume nothing, they contribute nothing to the reported match, and the engine's position is unchanged whether they succeed or fail. You are already using a zero-width construct: ^, $, and \b are all assertions too, just built-in ones.

Positive Lookahead: Several Conditions at One Position

The classic use is a password rule with independent requirements:

^(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$

Read it as four separate statements, all evaluated at position zero:

  • (?=.*[A-Z]) — somewhere ahead there is an uppercase letter. The engine scans forward, finds one, then rewinds to position zero.
  • (?=.*\d) — somewhere ahead there is a digit. Same scan, same rewind.
  • (?=.*[^A-Za-z0-9]) — somewhere ahead there is a character that is neither a letter nor a digit.
  • .{8,}$ — and now, actually consuming characters, the string is at least eight long and runs to the end.

Passw0rd! matches. password! fails the first assertion, Password! fails the second, Passw0rd fails the third. Writing this without lookaheads means either a combinatorial alternation over every ordering of the required characters, or three separate regex calls in your application code. The assertion version is shorter and, more importantly, tells you exactly which rule failed if you split it into three named checks.

Note the . in .* will not cross a newline unless you set the dotAll flag, so a multi-line input can quietly fail. Use [\s\S]* or the s flag if that matters.

Negative Lookahead: "Anything Except This"

(?!...) is how you express exclusion at a specific position. To accept any username except the literal string admin:

^(?!admin$)\w+$

Against admin, the assertion tries admin$ at position zero, succeeds, and therefore the negative lookahead fails — no match. Against administrator, the assertion tries admin$, matches admin but then hits i where it needs end-of-string, so admin$ fails and the negative lookahead succeeds; \w+$ then matches the whole username. The $ inside the assertion is doing real work: drop it and you would also reject administrator.

Two more shapes worth memorising:

  • \bfoo\b(?!\s+bar)foo, but not when followed by bar. The reported match is just foo.
  • ^(?!.*\bDEBUG\b).*$ with the multiline flag — every line that does not contain the word DEBUG. Filtering log files this way is far easier than inverting the logic by hand.

Lookbehind: Matching by What Came Before

Lookbehind checks the text on the left. To pull the number out of a price without capturing the currency symbol:

(?<=\$)\d+(?:\.\d{2})?

Against Subtotal $42.50 and $9 shipping, this matches 42.50 and 9 — the $ is verified but excluded. You could get the same values from a capture group with \$(\d+(?:\.\d{2})?) and then read group 1, but the lookbehind version means the match itself is already the value you want, which is what you need when you are highlighting, splitting, or replacing rather than extracting.

The negative form excludes by context. (?<!un)happy matches happy in "very happy" but not in "unhappy". (?<![\d.])\d{4}(?![\d.]) finds a standalone four-digit number and skips the 2024 inside 192.168.2024.

Support is the thing to check before you ship a lookbehind. JavaScript (ES2018+, including Safari 16.4 and later) and .NET allow arbitrary variable-length lookbehind. Java allows a bounded width, so (?<=a{1,5}) is fine but (?<=a+) is not. Python's built-in re requires a fixed width entirely — (?<=abc) works, (?<=ab?c) does not — though the third-party regex module lifts the restriction. Go's regexp and Rust's regex crate have no lookaround at all, by design: dropping it is part of how they guarantee linear-time matching.

A Lookaround-Only Replacement

Because assertions consume nothing, you can match an empty position and insert text there. Adding thousands separators to an integer is the standard demonstration. Replace every match of

(?<=\d)(?=(?:\d{3})+$)

with a comma. Against 1234567 the engine tests each position: after the 1 there are six digits remaining, which is a multiple of three, and the character behind is a digit — so a comma goes in. After the 2 there are five remaining, not a multiple of three, so nothing happens. After the 4, three remain — comma. The result is 1,234,567.

If you need to run in an engine without lookbehind, \B(?=(?:\d{3})+$) does the same job. \B is "not a word boundary", which at position zero of a digit string is false, so it blocks the leading comma that the lookbehind was preventing.

Positions are also where lookarounds pair naturally with splitting. Splitting camelCaseString on (?=[A-Z]) yields camel, Case, String — the delimiter is a position rather than a character, so nothing is thrown away. Drop that pattern into the Regex Split Tool with any identifier and you will see the pieces come out intact, which a plain [A-Z] separator would not manage.

Costs and Cautions

An assertion is not free, but the assertion itself is not usually what costs you — the pattern inside it is. (?=.*x) performs a forward scan of everything remaining, so three of those on a 100 KB string is three full scans before a single character is consumed. That is fine on a password field and wasteful on a document.

The more serious risk is putting a quantified group inside an assertion that is itself applied repeatedly. Shapes like (?=(\w+\s?)*$) combine two hazards at once and can take exponential time on input that nearly matches. Why Your Regex Is Slow: Catastrophic Backtracking explains the mechanism and how to spot it before it reaches production.

Two habits keep assertions safe. Anchor them so they cannot wander — ^(?=.{8,64}$) is bounded, (?=.*.*x) is not. And keep capture groups out of them unless you need the value, since a capture inside a lookahead is legal but easy to misread later.

Trying Them Out

Lookarounds are the construct people most often get right by accident and wrong under pressure, so build the habit of verifying them. Paste the password pattern into the Regex Tester & Matcher alongside a dozen candidate passwords and check that each rejection fails for the reason you expect. When an inherited pattern has assertions buried three levels deep, the Regex Explainer & Pattern Breakdown will lay out which parts consume text and which only look at it. Both tools run entirely in your browser, so the passwords and log lines you test with stay on your machine.

Once the zero-width idea settles, the rest is mechanical: lookahead checks the right, lookbehind checks the left, and adding ! inverts either one.

Frequently asked questions

What is a lookahead in regex?

A lookahead is a zero-width assertion that checks what follows the current position without consuming it. (?=foo) succeeds if foo comes next and (?!foo) succeeds if it does not. Because the cursor does not move, the text inside the assertion never appears in the match.

What is the difference between (?=) and (?!)?

(?=...) is a positive lookahead: the assertion succeeds when the enclosed pattern matches at the current position. (?!...) is a negative lookahead: it succeeds when the enclosed pattern fails to match there. The negative form is how you express "anything except this exact thing" at a specific position.

Does JavaScript support lookbehind?

Yes. Lookbehind — (?<=...) and (?<!...) — landed in ES2018 and works in every current browser and in Node 9 and later. Safari was the last holdout and shipped it in version 16.4, so if you must support older Safari, use a capture-group workaround instead.

Can a lookbehind be variable length?

It depends on the engine. JavaScript and .NET allow arbitrary variable-length lookbehind. Python's re module requires a fixed width, and Java allows a bounded width such as {1,10}. PCRE allows alternatives of differing fixed lengths. Go's regexp and Rust's regex crate support no lookaround at all.

How do I use regex to require multiple conditions at once?

Chain positive lookaheads at the start of the pattern. ^(?=.*[A-Z])(?=.*\d).{8,}$ requires an uppercase letter somewhere, a digit somewhere, and at least eight characters overall. Each assertion is evaluated from the same position and the cursor never moves, so the conditions are independent.

How do I match a word that is not followed by another word?

Use a negative lookahead directly after it. \bfoo\b(?!\s+bar) matches foo unless it is followed by whitespace and bar. Because the assertion consumes nothing, the reported match is just foo, with the text you tested against left outside it.

Are lookaheads slow?

A single assertion is cheap. The cost comes from what is inside one — (?=.*x) scans forward across the remaining string, so several of those on long input multiply the work. Keep assertions short, anchor them where possible, and avoid quantified groups inside them on untrusted input.

Try the related tools

Related articles