Regular Expressions for Lexing
Every compiler begins by turning raw source text into a stream of tokens. Before a scanner can recognize an identifier, a number, or a keyword, someone must write down precisely what an identifier or a number looks like. Regular expressions are the notation compiler writers use to answer that question. They do so exactly, unambiguously, and compactly. A single line like `[a-zA-Z_][a-zA-Z0-9_]` says more than a paragraph of English. It also says it more reliably than a description of "letters and digits starting with a letter." This matters because <a class="wiki-link" href="/wiki/cs-pl-lexical-analysis-tokens" data-track="wiki_article.body.wiki_link">Lexical Analysis and Tokens</a> is the entry point of every compiler, interpreter, and text-processing tool. Its correctness depends entirely on having an unambiguous specification of each token class. Regular expressions give lexical analysis a rigorous mathematical foundation. They define exactly the class of languages — the regular languages* — that can be recognized by a finite amount of memory. This is exactly the right amount of computational power for scanning tokens like numbers, identifiers, and operators. The big picture is a pipeline. A human writes regular expressions for each token category. A lexer generator (or a hand-built scanner) converts those expressions into a recognizer. That recognizer consumes source text one character at a time, emitting tokens. This article is about the first link in that chain. It covers the notation itself, its precise meaning, and the algebraic laws. These laws let us combine simple patterns into the specifications real languages need (string literals, comments, floating-point numbers, operators with multiple lengths). By the end, you will be able to read and write regular expressions for token classes with confidence. You will understand why regular expressions are exactly powerful enough for lexing, but no more. You will also see the conceptual bridge to the next stage of the pipeline: converting a regular expression into a runnable recognizer via finite automata.
- A regular expression is a compact algebraic notation for describing a set of strings — the token's language.
- Three operators — concatenation, alternation (`|`), and Kleene star (`*`) — are enough to define every regular language.
- Regular expressions and finite automata define exactly the same class of languages: the regular languages.
- Lexer generators compile a table of regular expressions (one per token class) into a single combined recognizer.
- Ambiguity between token classes is resolved by two standard disambiguation rules: longest match and rule priority.
- Regular expressions cannot count or nest arbitrarily deep, so they cannot match balanced parentheses or matching HTML tags.
A regular expression names the set of strings a token can be, and that naming is precise enough to be mechanically converted into a working scanner.
The idea
A regular expression is a formula, built from a small alphabet of operators, that denotes a set of strings called its language. Formally, over an alphabet , the regular expressions and the languages they denote are defined by structural induction:
- denotes , the language containing only the empty string.
- For each symbol , the expression denotes .
- If and are regular expressions denoting languages and , then:
- (concatenation) denotes .
- (alternation, or union) denotes .
- (Kleene star) denotes , i.e., zero or more concatenations of strings from .
Everything else — `+` (one or more), `?` (optional), character classes like `[a-z]`, the wildcard `.`, bounded repetition `{2,4}` — is syntactic sugar: convenient shorthand that expands into the three primitive operators above. For instance and . This is an important discipline: sugar makes expressions readable, but the expressive power of the notation is fixed by concatenation, alternation, and star alone. The set of languages describable this way is called the regular languages. A foundational theorem of formal language theory — due independently to Kleene's characterization of finite automata — states that a language is regular if and only if some regular expression denotes it, and if and only if some finite automaton accepts it. This three-way equivalence is why regular expressions are the right notation for lexing: they are declarative (you state what the tokens look like) while finite automata are operational (they say how to check membership efficiently), and the theorem guarantees a mechanical, complexity-bounded translation between the two — the subject of Building Scanners with Finite Automata. Regular languages are also exactly the languages recognizable with a constant amount of memory (no stack, no counters), which matches the resource profile of a lexer that must process gigabytes of source text in a single left-to-right pass.
How it works
Step 1 — Write one regular expression per token class. A lexical specification is a list of pairs : a regular expression and what to do when it matches (usually: emit a token of a given kind). ``` DIGIT = [0-9] LETTER = [a-zA-Z_] NUMBER = DIGIT+ ('.' DIGIT+)? IDENT = LETTER (LETTER | DIGIT)* KEYWORD_IF = 'if' WHITESPACE = (' ' | '\t' | '\n')+ ```
Step 2 — Resolve ambiguity between rules. Two strings can match more than one pattern (`if` matches both `IDENT` and `KEYWORD_IF`), and a single starting position can extend a match multiple ways (`ifx` could stop the `if` match early). Two disambiguation conventions, used by essentially every lexer generator, remove all ambiguity: 1. Longest match (maximal munch): among all rules that match a prefix of the remaining input, choose the one that matches the longest prefix. 2. Rule priority: if multiple rules match the same longest prefix, choose whichever rule was listed first in the specification. These two rules together turn "any regular expression might match" into "there is exactly one correct token" at every position, which is what makes automatic scanner generation possible.
Step 3 — Combine the rules into a single recognizer. Conceptually, the whole token table is merged into one big regular expression , tagged so the recognizer remembers which alternative fired. This combined expression is then compiled to a finite automaton (foreshadowed further in Building Scanners with Finite Automata); for now, treat the recognizer as a black box that, given input text and a position, returns the longest prefix matched and which rule matched it. The driver loop that uses this recognizer to tokenize a whole file is the same regardless of how the recognizer is built: ``` function TOKENIZE(input): tokens = [] pos = 0 n = length(input) while pos < n: (matchLength, ruleIndex) = LONGEST_MATCH(input, pos) // step 2's logic if matchLength == 0: error("no token matches at position", pos) lexeme = input[pos : pos + matchLength] if rules[ruleIndex].action != SKIP: // e.g. skip whitespace/comments tokens.append( Token(kind = rules[ruleIndex].kind, text = lexeme) ) pos = pos + matchLength tokens.append( Token(kind = EOF, text = "") ) return tokens function LONGEST_MATCH(input, pos): best_len = 0 best_rule = NONE for i in 0 .. length(rules) - 1: // rules in priority order len = MATCH_LENGTH(rules[i].regex, input, pos) // longest prefix from pos matched by rule i if len > best_len: best_len = len best_rule = i // strictly greater keeps the earlier-listed rule on ties (rule priority) return (best_len, best_rule) ``` `MATCH_LENGTH` is exactly the operation a compiled finite automaton performs in a single linear scan; writing it directly from the regular-expression definition (by trying every possible split at every alternation and every star) would be exponential, which is precisely the efficiency problem automaton compilation solves. The figure below shows the whole pipeline: source text flows through the combined recognizer built from the per-class regular expressions, and out comes a token stream.
Complexity & correctness
Time and space of the notation itself. A regular expression of length (counting symbols and operators) can be compiled to a nondeterministic finite automaton (NFA) with states via Thompson's construction, and that NFA can be converted to a deterministic finite automaton (DFA) with at worst states in the theoretical worst case, though in practice — and always for the subset of expressions used in real lexers — the DFA is small and -ish in size. Once a DFA exists, matching an input of length against it takes time with additional memory beyond the automaton's fixed table, because each input character causes exactly one table lookup and one state transition. This scanning bound is the entire reason regular expressions are the tool of choice for lexing: a compiler must process source files of arbitrary size in a single pass with a bounded memory footprint, and only a recognizer with a fixed, finite set of states can guarantee that.
Why longest-match plus priority is correct (uniqueness). The disambiguation rules are not arbitrary conveniences; they are what make tokenization a total function from input to token stream. Consider the alternative — shortest match, or no fixed tie-break. Then `if` could be scanned as `IDENT("i")` followed by `IDENT("f")`, or as `KEYWORD_IF`, and there would be no principled way to choose, so the language's grammar could not be defined without also fixing a scanning convention. By always preferring the longest prefix that matches any rule, and breaking remaining ties by earliest-declared rule, every input string is partitioned deterministically into a unique sequence of lexemes (as long as the specification has no "dead end," i.e., some rule matches every stretch of input, typically guaranteed by an error/catch-all rule). This is exactly the postulate lexer generators rely on to prove that their generated scanners are deterministic total functions on their input alphabet.
What regular expressions cannot do — the honest limitation. Regular languages are closed under union, concatenation, and Kleene star, but they are not closed under arbitrary nesting or counting. The pumping lemma for regular languages formalizes this: for any regular language , there is a constant (the "pumping length") such that every string with can be split as with , , and for all . Applying this to the language of balanced parentheses : choose ; any decomposition forced by the lemma puts entirely inside the 's, so pumping produces a string with more 's than 's, which is not in the language — a contradiction. Hence no regular expression can match balanced parentheses of arbitrary depth, and lexers correctly leave nested structure (matching brackets, matching quotes across balanced escapes, matching tags) to the parser's context-free grammar, discussed once the pipeline moves past lexing.
Worked example
Consider a miniature language with this token table (priority = order):
| # | Rule name | Regular expression | Action |
|---|---|---|---|
| 1 | `IF` | `if` | emit `IF` |
| 2 | `IDENT` | `[a-zA-Z_][a-zA-Z0-9_]*` | emit `IDENT` |
| 3 | `NUMBER` | `[0-9]+` | emit `NUMBER` |
| 4 | `PLUS` | `+` | emit `PLUS` |
| 5 | `ASSIGN` | `=` | emit `ASSIGN` |
| 6 | `WS` | `[ \t]+` | skip |
Trace `TOKENIZE` on the input string `"if x1=x1+10"`:
| pos | remaining input | rules matching a prefix, with lengths | longest / winner | lexeme | emitted token |
|---|---|---|---|---|---|
| 0 | `if x1=x1+10` | `IF`→2, `IDENT`→2 | tie at length 2, `IF` listed first → `IF` | `if` | `IF` |
| 2 | ` x1=x1+10` | `WS`→1 | `WS` | `" "` | (skipped) |
| 3 | `x1=x1+10` | `IDENT`→2 (`x1`), `NUMBER`→0 | `IDENT` | `x1` | `IDENT("x1")` |
| 5 | `=x1+10` | `ASSIGN`→1 | `ASSIGN` | `=` | `ASSIGN` |
| 6 | `x1+10` | `IDENT`→2 | `IDENT` | `x1` | `IDENT("x1")` |
| 8 | `+10` | `PLUS`→1 | `PLUS` | `+` | `PLUS` |
| 9 | `10` | `NUMBER`→2 | `NUMBER` | `10` | `NUMBER("10")` |
| 11 | (end) | — | — | — | `EOF` |
The crucial moment is position 0: without the tie-break rule, `if` is genuinely ambiguous between `IDENT` and the keyword `IF`, because both match a prefix of length 2. Listing `IF` before `IDENT` in the specification is what forces the correct outcome — this is exactly why lexer generators document that keyword rules must precede the general identifier rule. At position 5, longest match is what prevents `ASSIGN` from stealing only `=` incorrectly or `IDENT` from over-consuming into `=`; here only one rule matches, so the choice is forced, but the same mechanism is what correctly distinguishes, say, `<=` (length 2) from `<` (length 1) when both rules exist and the input actually contains `<=`.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Forgetting longest match | Naively trying rules until "one matches" instead of the longest one stops mid-identifier (`x1` scanned as `x` then `1`). | Always compute the maximal-length match across all rules before deciding. |
| Wrong keyword/identifier ordering | Listing `IDENT` before `IF` in priority order swallows keywords as identifiers on ties. | Put fixed keywords before the general identifier pattern, or check keyword-hood after matching `IDENT`. |
| Confusing `*` with `+` | `[0-9]*` also matches the empty string, silently allowing a zero-length "number" token that stalls the scanner. | Use `+` for "one or more" whenever zero-length matches would loop or misparse. |
| Greedy character classes swallowing delimiters | `.` inside a string-literal pattern consumes past the closing quote to the next* quote in the file. | Use a negated class like `[^"]` or explicit escape handling instead of unrestricted `.`. |
| Assuming regex can match nested structure | Trying to write a single regular expression for balanced parentheses or nested comments. | Recognize the pumping-lemma limit; hand nesting off to the parser's context-free grammar. |
| Zero-length match causing infinite loop | A rule like `WS = [ \t]*` can match length 0 at any position, so the driver loop never advances. | Require at least length 1 in every "skip" rule, and treat a zero-length best match as a lexical error. |
Pioneers & history
Origins · 1 minStephen Cole Kleene and how this was discovered
Kleene worked in the tradition of Church and Turing on computability and formal logic. In 1951, while analyzing McCulloch–Pitts neural nets for the RAND Corporation, he introduced the algebraic notation for "regular events" and proved it exactly captures the languages those finite-state nets can recognize — the theorem that bears his name and underlies every lexer built since.
Regular expressions moved from a tool of pure mathematical logic (finite automata theory in the 1950s) to a piece of everyday engineering infrastructure once Thompson's construction made compiling them practical, and Lex/Flex made that compilation available to any compiler writer without hand-building automata. Every modern parser-generator ecosystem — ANTLR, Bison-paired scanners, hand-rolled lexers in production compilers like those for C, Java, Go, and Rust — still expresses its token classes in essentially the notation Kleene defined in 1951.
Where this matters
Regular expressions specify the lexical layer of essentially every programming-language compiler and interpreter: GCC and Clang's C/C++ front ends, the CPython and Java tokenizers, and JIT compilers for JavaScript engines like V8 all begin with a hand-written or generated scanner built from regular-expression token rules. Tools such as Lex/Flex, ANTLR's lexer grammars, and Python's `re`-based tokenizers all rely on the same three-operator core described here. Beyond compilers, the same notation (extended with practical sugar) powers text search (`grep`, `sed`, `awk`), input validation (email and phone-number formats), network intrusion detection systems that scan packet payloads for signature patterns, log-processing pipelines, and syntax highlighting in editors and IDEs — all applications where a bounded-memory, single-pass, linear-time scan over text is exactly the right computational tool.
:::quiz[If a "whitespace" rule is mistakenly written as `[ \t]*` instead of `[ \t]+`, what bug can result?]
- The scanner rejects all valid whitespace
- The scanner may match a zero-length token and fail to advance, hanging the lexer ✓
- The DFA becomes exponentially large
- Keywords get treated as identifiers
Ask the tutor: How does Thompson's construction actually turn a regular expression into a finite automaton, step by step?
What is the difference between a regular expression and a token?
A regular expression is a pattern — a rule describing the shape of a whole category of strings, such as "one or more digits." A token is a concrete piece of output produced when some substring of the source file matches one of these patterns; it typically carries a kind (like `NUMBER`) and the actual matched text (the lexeme), such as `"42"`. The regular expression is written once, at compiler-design time, in the lexical specification; tokens are produced many times, at compile time, as the scanner consumes real source files.
Why not just use `.*` for everything and check the result afterward?
`.` is greedy and unrestricted: it matches as much as possible, including characters that belong to later tokens, so it swallows far more than intended (for example, matching to the last* closing quote in a file rather than the nearest one). It also throws away the whole point of having distinct token classes, since a single catch-all pattern cannot distinguish an identifier from a number or an operator. Per-class regular expressions combined via longest match and priority let the recognizer make a principled choice at every position instead of relying on fragile post-hoc string surgery.
Are regular expressions in tools like Python's `re` module the same as the ones used for lexing?
They share the same three core operators plus extensive sugar, but library implementations (PCRE-style "regex") often add backreferences (`\1`) and lookaround assertions, which push the notation beyond the regular languages into a strictly more powerful, sometimes exponential-time, matching problem. Lexer-generator regular expressions deliberately stay within the pure Kleene-style core so that they compile to a finite automaton with guaranteed linear-time matching, which is essential for scanning large source files predictably.
How does a lexer decide between two rules that match the same length prefix?
Ambiguity is resolved by rule priority: the lexical specification lists rules in a fixed order, and if the longest match is tied between two or more rules, the scanner picks whichever rule appears earliest in that list. This is why keyword rules (`if`, `while`, `return`) are always listed before the general identifier rule — otherwise every keyword would be swallowed as an ordinary identifier whenever the two rules tie on match length.
Can regular expressions describe every property we might want to check about a token?
No — they can only describe regular languages, which excludes anything requiring unbounded counting or matching, such as "the same number of opening and closing brackets" or "properly nested tags." Such properties, by the pumping lemma, provably cannot be regular. Lexers therefore only verify a token's local shape (digits, letters, operator symbols); global structural properties like balanced nesting are deferred to the parser, which uses a strictly more powerful context-free grammar.
What is the connection between this article and building an actual scanner?
This article covers the specification layer: writing precise regular expressions for each token class and understanding what they can and cannot express. The next conceptual step, covered separately, is the implementation layer — mechanically translating a regular expression into a nondeterministic finite automaton (via Thompson's construction), then into a deterministic finite automaton, so that matching runs in guaranteed linear time per character of input. That translation is what turns a declarative pattern into a working program.
Why do lexer generators forbid a rule that matches the empty string as its own token?
If a rule can match zero characters, the scanning loop can select that rule as the "longest match" at a position where no other rule matches at all, producing a token with empty text and never advancing its input position. A correctly designed lexical specification either forbids zero-length rules outright or treats a zero-length "best match" as a lexical error, ensuring the scanning loop is guaranteed to make progress on every iteration.