Lexical Analysis and Tokens
Every compiler begins with a wall of undifferentiated characters. These are bytes in a file with no notion of "word" or "number" yet. Before a parser can ask whether `x = y + 1;` obeys the grammar of a language, something must first act. That something must decide the line contains an identifier, an equals sign, another identifier, a plus sign, a number, and a semicolon. That "something" is the lexical analyzer, or lexer (also called a scanner or tokenizer). Its output — a stream of tokens — is the raw material every later stage of the The Compiler Pipeline depends on. This problem matters because raw text is the wrong granularity for almost every subsequent question a compiler must answer. Parsers reason about grammatical structure using a small, fixed vocabulary of symbol categories. They cannot reason character by character without an explosion of grammar rules. Such rules would be needed for whitespace, comments, and spelling variants of the same keyword. Lexical analysis solves this by collapsing an unbounded, noisy character stream. It becomes a much smaller, structured stream of typed symbols. Nearly every tool that processes structured text performs some version of this same reduction. This includes compilers, interpreters, linters, syntax highlighters, JSON/XML parsers, even regular shells parsing command lines. The big picture is a clean separation of concerns. Lexical analysis handles the local, regular structure of a language. This covers how digits form a number, how letters and digits form an identifier, where comments begin and end. Parsing handles the global, recursive structure instead. This covers how expressions nest, how statements compose into blocks. This division exists because the local structure is expressible with simple pattern matching. The global structure generally is not. This separation keeps both stages small, fast, and independently testable. This article gives a precise vocabulary for tokens, lexemes, patterns, and attributes. It offers a step-by-step description of how a scanner turns characters into tokens. This includes the tricky business of ambiguity resolution. It also provides a full complexity and correctness analysis, and a hand-traced worked example. Finally, it surveys the classic bugs that plague hand-written lexers. It sets up, without yet building, the machinery of Regular Expressions for Lexing and Building Scanners with Finite Automata.
- Lexical analysis converts a raw character stream into a stream of tokens, each an (token-class, attribute) pair.
- A lexeme is the actual substring matched; a token is the abstract category it belongs to (e.g., `ID`, `NUM`, `PLUS`).
- Scanners resolve ambiguity with two standard rules: longest match and rule priority (e.g., keywords beat identifiers).
- A single left-to-right pass over the input suffices: lexical analysis runs in O(n) time and space in the token count.
- Hand-written or generator-built (e.g., `lex`/`flex`), scanners are almost always implemented as finite automata.
- Getting lexing wrong — bad maximal-munch handling, unterminated strings, off-by-one line counting — causes some of the most common and subtle compiler-front-end bugs.
Lexical analysis replaces an unbounded character stream with a bounded vocabulary of tokens, turning "what pattern of characters is this" into "which fixed category does this belong to," so that parsing can reason about structure instead of spelling.
The idea
A token is a category — a named class of lexical units that the grammar of the language treats uniformly. Typical token classes include `ID` (identifiers), `NUM` (numeric literals), `STRING` (string literals), keyword tokens like `IF` or `WHILE`, and punctuation/operator tokens like `PLUS`, `LPAREN`, or `SEMI`. A lexeme is the concrete substring of the source program that was matched to produce a token — `count`, `42`, `"hello"`, `if`, `+`. Many different lexemes can map to the same token class (`x`, `count`, `total17` are all lexemes of class `ID`), but a token by itself discards the specific spelling. To avoid losing information the parser or later phases might need, each token carries an attribute (sometimes called a semantic value): for an `ID` token this is typically a pointer or index into a symbol table; for a `NUM` token it is the parsed numeric value; for a keyword or punctuation token there is often no attribute needed, since the token class alone determines meaning. A pattern is the rule — usually a regular expression — that describes the set of lexemes belonging to a token class. For example the pattern for `ID` might be "a letter followed by zero or more letters or digits," and the pattern for `NUM` might be "one or more digits, optionally followed by a decimal point and more digits." The lexical analyzer's job, formally, is: given the pattern set for token classes , and the source text , partition (after discarding whitespace and comments) into a sequence of lexemes such that each matches some pattern , and emit the token sequence . This partition is not automatically unique, so lexical analysis is as much about disambiguation as recognition. Two standard conventions make the partition well defined. The maximal munch (longest match) rule says that at each position, the scanner consumes the longest prefix of the remaining input that matches any pattern — so `<=` is read as one token, not `<` followed by `=`. The priority rule breaks ties among patterns that match the same longest lexeme, typically by rule order — so `if` matches both the `ID` pattern and the literal keyword pattern, and listing the keyword pattern first (or giving keywords explicit priority) ensures it is tokenized as `IF`, not `ID` with lexeme `"if"`. Whitespace and comments are usually matched by their own patterns and then discarded rather than emitted as tokens, since the parser's grammar has no need to see them (though position information from them, like line numbers, is retained for error messages).
How it works
Conceptually, a scanner is a loop: repeatedly skip irrelevant characters, then find the longest matching pattern for the token that begins at the current position, emit a token, and advance. The engineering reality is that this loop is implemented efficiently as a deterministic finite automaton (DFA), either hand-coded or generated from regular expressions by a scanner generator — the mechanics of that translation belong to Building Scanners with Finite Automata, but the algorithm below describes the abstract behavior any correct scanner must exhibit, independent of how the matching is implemented internally. The key data the scanner threads through the computation is a cursor into the input and, at each step, a record of the longest match found so far together with which token class it belongs to — this second piece is what lets maximal munch and rule priority be resolved together in a single scan. ``` function TOKENIZE(source): tokens ← empty list pos ← 0 line ← 1 n ← length(source) while pos < n: skip whitespace and comments starting at pos, advancing pos and incrementing line on each newline seen if pos ≥ n: break // trailing whitespace/comments only bestLen ← 0 bestClass ← NONE bestPriority ← +∞ for each (tokenClass, pattern, priority) in patternTable: len ← LONGEST_PREFIX_MATCH(pattern, source, pos) // LONGEST_PREFIX_MATCH returns 0 if no match at pos if len > bestLen: bestLen ← len bestClass ← tokenClass bestPriority ← priority else if len == bestLen and len > 0 and priority < bestPriority: bestClass ← tokenClass // same length, higher priority wins bestPriority ← priority if bestLen == 0: report LEXICAL_ERROR at (pos, line) pos ← pos + 1 // error recovery: skip one char continue lexeme ← source[pos .. pos + bestLen - 1] attribute ← BUILD_ATTRIBUTE(bestClass, lexeme) // e.g. intern in // symbol table, parse // numeric literal if bestClass is not a DISCARD class (whitespace/comment already handled above): append (bestClass, attribute, line) to tokens pos ← pos + bestLen append (EOF, ε, line) to tokens return tokens ``` Two subroutines are doing real work here and deserve to be named explicitly. `LONGEST_PREFIX_MATCH(pattern, source, pos)` finds, for one pattern, the length of the longest prefix of `source` starting at `pos` that the pattern accepts — in a real implementation this is a DFA simulation that runs until it can no longer transition, remembering the last position at which it was in an accepting state. `BUILD_ATTRIBUTE` performs class-specific post-processing: interning an identifier lexeme into a symbol table (so repeated occurrences of `count` share one table entry) or converting a numeric lexeme's digits into an integer or floating-point value. The following diagram shows the pipeline view: a character stream flows through the scanner and out as a token stream, with the symbol table filled in as a side effect.
Complexity & correctness
Time complexity. Assume the patterns are compiled into DFAs (the standard implementation, elaborated in Building Scanners with Finite Automata). Simulating a DFA on an input of length takes time, since each character triggers exactly one table lookup and one state transition. The `TOKENIZE` loop advances `pos` by at least one character on every iteration (either by matching a lexeme of length or by the error-recovery skip), and every character is examined by the matching process a bounded number of times. If all patterns are merged into a single combined DFA — the standard scanner-generator technique — the whole tokenization pass runs in time on an input of characters, independent of the number of token classes, because the combined automaton processes each input character exactly once regardless of how many original patterns it encodes. If patterns are instead tried one at a time as in the pseudocode above (a naive but illustrative version), the cost is for patterns, since each of the (amortized) match attempts tries all patterns; real implementations always merge the automata precisely to eliminate this factor. Either way, lexical analysis is asymptotically linear in the size of the source, which is why it is never the bottleneck of compilation.
Space complexity. The automaton itself is in the total size of the patterns, a constant with respect to the input. The token stream produced is in the worst case (e.g., source consisting entirely of single-character tokens like `+++++...`), but more precisely where is the number of lexemes — in practice since identifiers and numbers are usually several characters long. The symbol table adds space where is the number of unique identifiers, since repeated occurrences share one entry.
Correctness. The central correctness claim is that maximal munch plus rule priority produces a partition of the input that is (a) total — every non-whitespace, non-comment character is eventually consumed into some token — and (b) deterministic — the same input always yields the same token sequence. Totality holds by an inductive argument on the loop: at the start of every iteration, `pos` points to either end-of-input (loop terminates) or the start of an unconsumed suffix; the loop body either matches a nonzero-length lexeme (advancing `pos`) or reports an error and skips one character (also advancing `pos`), so `pos` strictly increases and the loop terminates after at most iterations having consumed every character. Determinism holds because `bestLen` and `bestClass` are computed by a total order — pick strictly longer matches, then break exact ties by a fixed priority ranking — so no two runs on the same input can produce a different winning pattern. A subtler correctness property is that maximal munch is the right disambiguation policy for real languages, not merely a convenient one. Consider the alternative "first match wins regardless of length": scanning `<=` with patterns for `<` (LESS) and `<=` (LEQ) listed in that order would greedily emit `LESS` for `<`, then fail to match `=` against any operator-starting pattern in the intended way, misparsing the input. Longest match avoids this class of error automatically, which is precisely why it is the near-universal convention in scanner generators such as `lex`/`flex` and in hand-written lexers alike. The one place this policy requires explicit help from rule priority is when the longest match is genuinely tied between two classes with different intended meaning — the canonical case being keywords versus identifiers, where `if` is simultaneously a maximal match for the identifier pattern and for the literal-keyword pattern of equal length, resolved only by declaring keyword patterns higher priority.
Honest tradeoffs. Linear-time, single-pass scanning assumes token boundaries can be found without looking arbitrarily far ahead or backward — true for most languages, but not universally. Some constructs need small bounded lookahead (distinguishing `.` as end-of-statement from `.` beginning a floating literal like `.5`), which a DFA handles fine since lookahead is folded into longer match attempts, not violating linearity. Genuinely context-sensitive lexical decisions — Python's significant indentation, or C++'s notorious `template>>` versus `>>` as a shift operator — cannot be resolved by a context-free DFA scan alone and require either a stateful lexer (tracking indentation levels on a stack) or cooperation with the parser (as C++ does for `>>`). These are documented exceptions, not failures of the theory: the pure token-DFA model is correct for the regular, context-free-at-the-lexical-level core that covers the overwhelming majority of language constructs.
Worked example
Trace the scanner on the source line (with an intentionally tricky mix of an operator ambiguity, a keyword/identifier collision, and a floating literal): ``` if (x1 <= 3.14) x1 = x1+1; ``` Assume the pattern table (with priority: lower number = higher priority) is:
| Priority | Token class | Pattern (informal) | |
|---|---|---|---|
| 0 | `IF` | literal `if` | |
| 1 | `ID` | letter (letter\ | digit)* |
| 1 | `NUM` | digit+ (`.` digit+)? | |
| 1 | `LEQ` | `<=` | |
| 1 | `LESS` | `<` | |
| 1 | `LPAREN`,`RPAREN`,`PLUS`,`ASSIGN`,`SEMI` | single chars `(`,`)`,`+`,`=`,`;` |
| pos | remaining input (start) | longest matches tried | winner (len, class) | lexeme | emitted token | notes |
|---|---|---|---|---|---|---|
| 0 | `if (x1 <= ...` | `IF` len 2, `ID` len 2 (tie) | `IF` (priority 0 beats `ID`) | `if` | `(IF)` | keyword/identifier tie broken by priority |
| 2 | ` (x1 <= ...` | whitespace | — | ` ` | (discarded) | |
| 3 | `(x1 <= ...` | `LPAREN` len 1 | `LPAREN` | `(` | `(LPAREN)` | |
| 4 | `x1 <= 3.14) ...` | `ID` len 2 | `ID` | `x1` | `(ID, →#1)` | new symbol table entry #1 = `x1` |
| 6 | ` <= 3.14) ...` | whitespace | — | ` ` | (discarded) | |
| 7 | `<= 3.14) ...` | `LESS` len 1, `LEQ` len 2 | `LEQ` (longer wins) | `<=` | `(LEQ)` | maximal munch: `<=` beats `<` |
| 9 | ` 3.14) x1 = ...` | whitespace | — | ` ` | (discarded) | |
| 10 | `3.14) x1 = ...` | `NUM` len 4 | `NUM` | `3.14` | `(NUM, 3.14)` | attribute = parsed float |
| 14 | `) x1 = x1+1;` | `RPAREN` len 1 | `RPAREN` | `)` | `(RPAREN)` | |
| 15 | ` x1 = x1+1;` | whitespace | — | ` ` | (discarded) | |
| 16 | `x1 = x1+1;` | `ID` len 2 | `ID` | `x1` | `(ID, →#1)` | same symbol, reuses entry #1 |
| 18 | ` = x1+1;` | whitespace | — | ` ` | (discarded) | |
| 19 | `= x1+1;` | `ASSIGN` len 1 | `ASSIGN` | `=` | `(ASSIGN)` | |
| 20 | ` x1+1;` | whitespace | — | ` ` | (discarded) | |
| 21 | `x1+1;` | `ID` len 2 | `ID` | `x1` | `(ID, →#1)` | reuses entry #1 again |
| 23 | `+1;` | `PLUS` len 1 | `PLUS` | `+` | `(PLUS)` | maximal munch confirms no `++` pattern exists here, so len is 1 |
| 24 | `1;` | `NUM` len 1 | `NUM` | `1` | `(NUM, 1)` | |
| 25 | `;` | `SEMI` len 1 | `SEMI` | `;` | `(SEMI)` | |
| 26 | (end) | — | — | — | `(EOF)` |
Final token stream: ``` (IF) (LPAREN) (ID,#1) (LEQ) (NUM,3.14) (RPAREN) (ID,#1) (ASSIGN) (ID,#1) (PLUS) (NUM,1) (SEMI) (EOF) ``` Notice three things the trace demonstrates concretely: the keyword/identifier tie at position 0 is resolved purely by priority since both patterns matched the same two characters; the `<`/`<=` tie at position 7 is resolved purely by length since `LEQ`'s match is strictly longer; and the symbol table collapses three occurrences of `x1` into one entry, which is exactly the space saving noted in the complexity section.
Common pitfalls & traps
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Greedy-but-not-maximal matching | Hand-rolled scanner reads `<` then separately re-checks for `=`, mishandling `<=` or, worse, silently accepting `< =` with a space as if identical | Always compute the longest match across all patterns before committing, per maximal munch |
| Keyword table forgotten | `if`, `while`, etc. tokenized as generic `ID`, breaking the grammar that expects a distinct `IF` token | Reserve a keyword lookup (hash table or priority rule) that fires after — or as part of — identifier matching |
| Off-by-one line/column tracking | Newlines inside skipped whitespace or inside multi-line comments/strings not counted, producing wrong error locations later | Increment line counters exactly once per `\n` consumed, including inside comment- and whitespace-skipping code |
| Unterminated string/comment not detected | Scanner runs to end-of-file inside a `"..."` or `/.../` with no closing delimiter, silently producing a corrupted or truncated token stream | Track an explicit "in string/comment" state and raise a lexical error at end-of-file if still inside one |
| Numeric literal edge cases | `3.` or `.5` or `1e` (incomplete exponent) matched inconsistently across the pattern for `NUM`, or integer overflow silently wrapping in `BUILD_ATTRIBUTE` | Precisely specify the `NUM` pattern's optional parts and check attribute-construction for overflow, reporting an error rather than wrapping |
Pioneers & history
Origins · 1 minKen Thompson and how this was discovered
Ken Thompson's construction connecting regular expressions to finite automata, first published for the QED and `ed` text editors, became the mathematical basis for how virtually every lexical analyzer — including `lex` and its descendants — converts a set of token patterns into an executable matcher.
Where this matters
Every production compiler front end — GCC, LLVM/Clang, the Java and C# compilers, the V8 and SpiderMonkey JavaScript engines — begins with a lexical analysis phase structurally identical to what is described here, whether hand-written for speed (as in most industrial compilers) or generated from a specification (as with `flex`, ANTLR's lexer layer, or similar tools in other ecosystems). Beyond compilers, the same character-to-token reduction underlies JSON and XML parsers, configuration-file readers, regular-expression engines themselves, SQL query parsers in database engines, and syntax highlighters and language servers in editors like VS Code, which re-run lightweight lexers on every keystroke to colorize code in real time. Network protocol parsers and command-line shells performing word-splitting and quoting are, at heart, doing the same maximal-munch tokenization. Understanding lexical analysis is also the direct prerequisite for the two mechanisms that implement it in practice: Regular Expressions for Lexing, which formalizes the patterns, and Building Scanners with Finite Automata, which compiles those patterns into the linear-time automaton a real scanner runs.
Ask the tutor: How would you extend this scanner design to correctly handle Python-style significant indentation, which a single DFA cannot express on its own?
Why can't the parser just work directly on characters, skipping lexical analysis?
It could in principle, but grammars expressed at the character level become far larger and slower to process. Every grammar rule would need to spell out sequences of individual letters and digits for every keyword and identifier, and rules for skipping whitespace and comments would have to be threaded through every other rule. Lexical analysis factors out this low-level, purely regular structure into a separate, much cheaper linear-time pass, leaving the parser a small fixed alphabet of token classes to reason about with its more powerful (and more expensive) context-free grammar machinery. This separation is also what lets the two phases be developed, tested, and optimized independently.
What exactly does a token's "attribute" contain, and why not just store the lexeme string?
The attribute is whatever semantic information later compiler phases actually need, computed once at scan time so it need not be recomputed repeatedly. For an identifier, that is typically a reference into the symbol table rather than the raw string, so that every later use of the same name resolves to one shared entry (for scope and type checking) instead of a fresh string each time. For a numeric literal, the attribute is the already-parsed numeric value (an integer or float), since re-parsing the digit string later would be redundant work. Storing only the lexeme would force every downstream phase to redo this parsing and lookup work itself.
How does a scanner decide where one token ends and the next begins, in general?
It applies the maximal munch rule: starting from the current position, it tries every token pattern and finds the single longest prefix of the remaining input that any pattern accepts, then commits to that lexeme. If two different patterns tie for the same longest length, a fixed priority order (usually keywords before identifiers, and more specific operators before shorter prefixes of themselves) picks the winner. This combination guarantees a unique, deterministic partition of the whole input into tokens, which is essential since the parser downstream cannot function correctly on an ambiguous token stream.
Are whitespace and comments tokens too?
Not usually in the token stream the parser sees. Most lexers do match whitespace and comments against their own patterns — the scanner has to recognize them precisely to know where they end — but then discard them rather than emit them as tokens, since the grammar of the language does not assign them any structural meaning. Some tooling, such as pretty-printers, diff tools, or language servers that must reproduce formatting exactly, keeps them as a side channel (sometimes called "trivia") attached to adjacent real tokens, rather than as part of the main token stream itself.
What happens when the lexer encounters a character that matches no pattern at all?
This is a lexical error, and a correct scanner must not simply crash or hang. Standard practice is to report a diagnostic naming the offending character and its line and column, then apply some recovery strategy — commonly skipping just that one character and resuming scanning — so that a single bad character does not prevent the rest of the file from being checked and does not cascade into a flood of misleading downstream errors. The specific recovery policy is a quality-of-implementation choice; the correctness requirement is only that the scanner terminates and reports the error rather than looping or misclassifying the bad input as a valid token.
Is lexical analysis always regular, i.e., always implementable by a DFA?
The core, per-token matching problem is regular for the overwhelming majority of language constructs, which is exactly why DFAs are the standard implementation technique, as covered in Building Scanners with Finite Automata. Some languages add lexically context-sensitive wrinkles that a pure DFA cannot resolve alone — Python's indentation-based block structure, or C++'s ambiguity between `>>` as a shift operator and as two closing template brackets — and these are handled by extra state (an explicit indentation stack) or cooperation with the parser, rather than by abandoning the DFA-based approach for the rest of the language.
Why does the symbol table matter for lexical analysis specifically, rather than only for later semantic analysis?
The lexer is the natural place to intern identifiers — assign each unique spelling one shared table entry — because it is the first and only phase that sees every raw occurrence of every name in the source text. Doing this interning immediately means later phases (parsing, type checking, code generation) never need to compare strings character by character to check whether two identifiers refer to the same name; they compare cheap table indices instead. The lexer typically does not yet resolve scoping (which declaration a use refers to) — that remains a semantic-analysis concern — but it does the cheap, purely syntactic bookkeeping of collecting distinct spellings.