Source Locations

File, line, column, offset — four numbers that sound trivial and are not. Byte offsets versus (line, column), and the UTF-16 code unit that LSP counts by and your compiler almost certainly does not.

The question

How does a compiler know which line and column an error is on, and why do the numbers sometimes disagree between tools?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A position is fundamentally one integer: a byte offset into a source buffer. Everything human-facing — file, line, column — is derived from it on demand, by looking the offset up in a table of line-start offsets. That inversion is the design: the pipeline carries a compact, cheap, arithmetic-friendly number, and pays the conversion cost only at the boundary where a human reads the result. The question this representation exists to answer is "which characters caused this", asked by a phase that ran long after the characters were forgotten.

What this phase may assume or do

Every later phase is entitled to assume the lexer recorded, for every token, the exact half-open byte range it consumed, and that ranges are indices into the *original, unmodified* source buffer. Break either assumption — normalise line endings after lexing, expand tabs, decode escapes in place, strip a BOM without adjusting offsets — and every position in the compiler shifts by an amount that varies per file. The failure is silent and it is unrecoverable at the point where it is noticed, because the information needed to correct it is gone.

Key points

  • A position is one byte offset. File, line and column are derived at render time, because every internal operation on positions is integer arithmetic.
  • Line and column come from a per-file sorted array of line-start offsets plus a binary search — built once during lexing, read constantly.
  • Four units compete for the word "column": UTF-8 bytes, Unicode scalar values, UTF-16 code units, and grapheme clusters. They give different answers for the same position.
  • LSP counts columns in UTF-16 code units by default; nearly every compiler counts bytes. Every language server has a conversion layer, and every bug in it appears only on lines with non-ASCII characters.
  • LSP 3.17 allows negotiating utf-8 or utf-32, but only when both client and server opt in.
  • Base conventions differ too: compilers display 1-based, LSP uses 0-based, and tabs have no agreed width.
  • Offsets must index the original buffer. Normalising line endings, stripping a BOM or expanding tabs before recording positions corrupts every position by a per-file amount.
  • None of this can be retrofitted: a frontend that did not record offsets from the first token cannot get them back.

Four numbers, and only one of them is stored

A diagnostic points at something: main.rs, line 12, column 9. That looks like three or four independent facts, and storing them per node would be the obvious design. It is not the design anyone uses.

The reason is arithmetic. Compilers compare, merge, sort and nest positions constantly — is this token inside that macro expansion, does this span contain the cursor, what is the union of these two ranges — and every one of those operations is a single integer comparison on offsets and a mess on (line, column) pairs. A position is therefore one number, and rustc goes further: it uses one global offset space across every file in the crate, so a single u32 identifies the file *and* the position within it, by binary searching a table of per-file start offsets.

Line and column are computed only when a diagnostic is being rendered. Each source file carries a sorted array of the byte offsets at which lines start; a binary search over it converts an offset to a line number, and the column is the difference from that line's start — counted in whichever unit you have decided to count in, which is the next section and the one that causes the trouble. See [[binary-search]] in DSA for the lookup, and note that this is one of the few places in a compiler where a plain sorted array beats every fancier structure, because it is built once and read many times.

What each form is good for
FormCost of the operation you do mostUsed for
Byte offset (one integer)Compare, merge, nest, contain: all O(1) integer opsEverything internal — AST nodes, IR, symbol tables, macro expansion
File + offsetOne binary search over per-file startsMulti-file compilation with a single global offset space
(Line, column)One binary search over line starts, plus a decode of the line prefixRendering a diagnostic; nothing else
Line + full line textA slice of the bufferThe gutter and underline in a rendered diagnostic

The column problem

Column 9 of what? The answer is not obvious and there are four defensible ones, and the tools you use every day do not agree.

Take the source let x = "café";. It is sixteen bytes, fifteen characters and fifteen UTF-16 code units, because é is two bytes in UTF-8 but one of everything else. A compiler storing byte offsets records the string literal as [8,15) — seven bytes. An editor counting UTF-16 units, which is what LSP specifies by default, records it as [8,14). Compute an underline length from the byte span and you draw seven carets under a six-column token. It is off by one, only on lines with non-ASCII content, and only in the part of your product that renders errors.

The spans in the device below are UTF-16 units, because that is what this page slices with — the same choice, and the same trap, one layer down. The three columns disagree exactly where you would expect, and the notes name the byte answer alongside.

Spans over let x = "café"; — sixteen UTF-8 bytes, fifteen characters, fifteen UTF-16 units
let x = "café";

Read it asThe token stream is correct. Every offset is a true byte index into the buffer, and every internal operation on these spans — containment, union, comparison — is right. The only thing that is wrong is the moment a human is told a column number, and that is precisely where four different conventions collide.

The four units, and the one that will bite you

specLSP specifies UTF-16 code units as the default PositionEncodingKind, with utf-8 and utf-32 available since 3.17 only when the client advertises general.positionEncodings and the server selects one. This is a protocol requirement, not an implementation choice: a server that sends UTF-8 columns to a client that did not negotiate is non-conforming, and the symptom is misplaced squiggles on exactly the lines a test suite of ASCII fixtures will never contain.

The specific, real gotcha is the Language Server Protocol. LSP positions are (line, character) pairs where `character` is an offset in UTF-16 code units by default. Not bytes. Not Unicode scalar values. UTF-16 code units — the thing JavaScript's String.length counts — because the protocol was designed around VS Code, whose editor buffer is UTF-16.

Almost no compiler stores positions that way. rustc, Go, Clang and tree-sitter all work in UTF-8 bytes. So every language server contains a conversion layer, and every bug in that layer looks the same: everything works perfectly until a file contains an emoji or an accented character, after which every diagnostic on that line is offset, every rename edits the wrong range, and every hover is one character off. Astral-plane characters make it worse — an emoji is one scalar value, four UTF-8 bytes and two UTF-16 code units — so a line with one emoji shifts positions by different amounts under each convention.

LSP 3.17 added negotiation: a client advertises general.positionEncodings and a server may pick utf-8 or utf-32 instead. It is opt-in on both sides, so the UTF-16 default is still what you get unless both ends implement the negotiation.

Four units, four answers for the same positionspec
UnitCountsWho uses itColumn of `;` in `let x = "café";`
UTF-8 byteimplementationBytesrustc, Go (token.Pos), Clang, tree-sitter, most compilers15
Unicode scalar valueCode pointsPython str indices, many linters, Swift's UnicodeScalarView14
UTF-16 code unitspec16-bit units; astral chars count as 2LSP default, JavaScript String.length, Java String, .NET14
Grapheme clusterUser-perceived charactersTerminal rendering, caret alignment, Swift Character14 — but 13 if the é were e + combining accent

The other conventions nobody agrees on either

Two more, both smaller and both real. Base: rustc, GCC and Clang display 1-based lines and 1-based columns; LSP uses 0-based lines and 0-based characters; some tools mix them, displaying 1-based lines with 0-based columns. There is no principle here, only convention, and the conversion is one addition in the place where the two meet — which is exactly the kind of code that gets it right for lines and wrong for columns.

Tabs: is a tab one column or eight? For an underline to line up under a line indented with tabs, the renderer must expand them the way the reader's terminal will, which it cannot know. rustc renders tabs as four spaces in its own output and adjusts the underline accordingly; GCC has -ftabstop=N for exactly this. Whatever the choice, the *stored* position must remain a byte offset — expanding tabs before storing turns the position into something that cannot be mapped back to the file.

And one thing that is not a convention but a rule: positions index the source buffer *as read from disk*, before any normalisation. Line-ending translation, BOM stripping, tab expansion, escape decoding and macro expansion all change the text; every one of them must either happen before offsets are assigned or maintain an explicit mapping. This is why rustc's SourceMap records the byte positions of any normalisation it performed, and why source maps exist at all for languages that are compiled from other languages — see [[source-maps]].

What it costs to get this right, and what it costs to get it wrong

The cost of doing it properly is small and structural: one integer per position rather than three, a line-start table per file built once during lexing, a conversion at the rendering boundary, and a decision about units written down somewhere. Under a hundred lines of code, and it must exist before the first token is produced.

The cost of getting it wrong is that it cannot be fixed later. A frontend that stored line and column instead of offsets cannot cheaply add span merging. One that lost the original buffer cannot render an underline. One that normalised text before recording offsets has positions that are wrong by a per-file amount nothing can recover. Every diagnostic, every jump-to-definition, every rename and every source map in the product's future depends on a decision made in the lexer on day one — which is why this is a lesson and not a footnote, and why the pipeline explorer's ability to link a click in the assembly panel back to a character in the source panel is the whole argument made visible.

Offset to (line, column), in all four units
1class SourceFile {
2 readonly text: string
3 /** Byte offset at which each line starts. Built once, during lexing. */
4 readonly lineStarts: number[]
5
6 /** O(log n) via binary search over lineStarts. */
7 lineOf(offset: number): number {
8 let lo = 0, hi = this.lineStarts.length - 1
9 while (lo < hi) {
10 const mid = (lo + hi + 1) >> 1
11 if (this.lineStarts[mid] <= offset) lo = mid
12 else hi = mid - 1
13 }
14 return lo // 0-based; add 1 for display
15 }
16
17 /**
18 * The column, in whichever unit the consumer asked for.
19 * Every one of these is a defensible answer, and they differ.
20 */
21 columnOf(offset: number, unit: 'byte' | 'codepoint' | 'utf16'): number {
22 const start = this.lineStarts[this.lineOf(offset)]
23 const prefix = this.text.slice(start, offset) // the line up to the position
24
25 switch (unit) {
26 case 'byte': return utf8Length(prefix) // rustc, Go, Clang
27 case 'codepoint': return [...prefix].length // counts scalar values
28 case 'utf16': return prefix.length // LSP default — JS native
29 }
30 }
31}
32
33// The bug this exists to prevent: sending byte columns to an LSP client.
34// Correct until a file contains 'é' or an emoji, then wrong on that line only.

The three cases are three different numbers for the same offset, and nothing in the type system stops you returning the wrong one. The practice that actually prevents the bug is naming the unit in the type — Utf16Column and ByteColumn as distinct types — so that the conversion has to be written explicitly at the one boundary where it belongs.

How it works

The steps, in the order the compiler takes them.

  • The lexer reads the source file into a buffer without normalisation and records its length.
  • As it scans, it appends the offset after each newline to a line-start array, which is therefore sorted by construction.
  • Each token records the half-open byte range [start, end) it consumed.
  • Every AST node built from those tokens records the union of their ranges; every later representation carries the range forward.
  • When a diagnostic is rendered, the offset is binary-searched in the line-start array to get a 0-based line index.
  • The column is computed by measuring the slice from that line's start to the offset, in the unit the consumer requires.
  • For multi-file compilation, a global offset space concatenates files, so one integer identifies both file and position via a second binary search over per-file starts.
  • A language server converts to the negotiated encoding — UTF-16 unless both ends agreed otherwise — at the protocol boundary, and nowhere else.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • Squiggles in the editor appear one or two characters to the right of the real problem, but only in files containing accented characters or emoji, and only for users who write in those languages.
  • A rename refactoring replaces the wrong byte range in a file with a non-ASCII comment, corrupting a line the user never touched.
  • The caret underline in a terminal diagnostic is one character too long, which nobody reports because it still looks approximately right.
  • Every position in a file is shifted by three bytes because a UTF-8 BOM was stripped after offsets were assigned; the errors point one construct earlier for that file only.
  • On Windows checkouts, positions are off by one per preceding line because CRLF was normalised to LF after lexing recorded offsets.
  • Underlines are misaligned in tab-indented files, differently in each user's terminal, and the bug is unreproducible on the maintainer's machine.
  • A test suite of ASCII fixtures passes completely while every non-English user sees misplaced diagnostics.

When it helps

  • Debugging misplaced diagnostics: the answer is almost always a unit mismatch or a normalisation that happened after offsets were recorded, and knowing the four units narrows it in minutes.
  • Implementing or consuming LSP, where the UTF-16 default is the single most common source of position bugs.
  • Designing a frontend: deciding on byte offsets plus a line-start table before the lexer is written is nearly free, and impossible afterwards.
  • Reading someone else's compiler: whether positions are offsets or line/column pairs tells you immediately how good its diagnostics can become.

When it hurts

  • Over-engineering a tool that will only ever see ASCII and only ever print file:line. A simple line counter is fine and the machinery buys nothing.
  • Assuming a grapheme-accurate column is achievable. It depends on the Unicode version, the font and the terminal, so "the column the user sees" is not a well-defined number for any renderer to target.

What it costs

Every one of these is paid by something.

  • Storing one offset instead of a (line, column) pair buys O(1) comparison, merging and containment for every span operation in the compiler, and pays a binary search plus a prefix decode at every render — which is fine, because rendering happens thousands of times and span arithmetic happens millions.
  • A global offset space across all files buys a 32-bit position that identifies file and location together, halving the memory of every span in the AST, and pays an extra binary search per lookup plus a hard limit on total source size that large builds do occasionally hit.
  • Byte offsets buy exactness against the file on disk and cost a conversion layer at every boundary that counts differently — LSP, source maps, terminals — and each such layer is somewhere the bug can live.
  • Recording positions before any normalisation buys correctness and costs the convenience of working with clean text everywhere downstream: every consumer must handle CRLF, BOMs and tabs itself, or maintain an explicit mapping.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Store (line, column) pairs directly, as some scripting-language implementations do. Rendering is trivial and every span operation becomes a two-field comparison with special cases, which is why nothing that needs to merge spans does it.
  • Store an interned span id resolved through a side table, which is what rustc does for spans carrying macro-expansion context — see [[spans-and-ranges]].
  • Count in UTF-16 code units natively, as a frontend written in JavaScript or Java effectively must. Removes the LSP conversion and creates one against every other tool, including the file on disk.
  • Carry no positions at all beyond a line number, which is what very early compilers and some minifiers do. Cheap, and it forecloses underlines, secondary spans, quick fixes and source maps permanently.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Write a file containing an accented character or emoji before an error, and compare the column your compiler reports against the column your editor shows in its status bar. A mismatch is the unit problem, live.
  • rustc: --error-format=json emits byte_start, byte_end, line_start, column_start for every span, so you can see both representations for the same diagnostic side by side.
  • Go: go/token's FileSet and Position are the canonical small implementation of offset-plus-line-table; token.Position carries Offset, Line and Column and the column is in bytes.
  • LSP: run a server with tracing on ("trace": "verbose" in most clients) and inspect the character values in publishDiagnostics for a line containing an emoji. If they match byte counts rather than UTF-16 counts, the server is non-conforming.
  • tree-sitter reports both start_byte/end_byte and start_point/end_point (row, column) for every node, and its column is in bytes — a concrete example of the two representations coexisting in one API.
  • Our pipeline explorer at /compilers/pipeline threads offsets through every stage; clicking a node in the assembly panel highlights the originating characters, which is only possible because nothing in between discarded them.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Column means the same thing everywhere." It means at least four different things, and the tools in a single editing session — compiler, language server, editor — routinely use three of them.
  • "UTF-8 makes this simple because everything is bytes." It makes the *storage* simple. The moment a number is shown to a human or sent over LSP, the unit question returns.
  • "We can convert line and column back to an offset if we need to." Only if you still have the file exactly as it was read. If anything normalised it, the mapping is gone.
  • "Emoji are just another character." An emoji is one scalar value, four UTF-8 bytes and two UTF-16 code units, and possibly part of a longer grapheme cluster with a skin-tone modifier or a ZWJ sequence. It differs under every convention at once, which is why it is the standard test case.
  • "This only matters for internationalised software." It matters for any source file containing a non-ASCII character in a comment or a string literal, which in practice is most codebases.

Misconceptions

The claim, and what is actually true.

Storing line and column is simpler than storing an offset.
It is simpler to render and worse at everything else. Merging two spans, testing containment and sorting positions are all one integer operation on offsets and a special-cased comparison on pairs.
If the tests pass, the positions are right.
Position bugs live exclusively in non-ASCII source. A fixture suite written in English exercises none of the code paths where the four units differ.
The compiler and the editor agree because they read the same file.
They agree on the bytes and frequently disagree on how to number them. That disagreement is what the LSP encoding negotiation exists to settle, and it is opt-in.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Every token records where it came from as a byte offset into the file. Line and column numbers are worked out later, only when an error needs printing, by looking the offset up in a list of where each line starts. The complication is that "column" is ambiguous: bytes, characters and the 16-bit units that editors count are three different numbers, and picking the wrong one puts your error squiggle in the wrong place on any line containing an accent or an emoji.

practical

Store byte offsets, build a line-start table in the lexer, and convert once at the boundary where a human or a protocol consumes the number. If you write a language server, the default is UTF-16 code units and your compiler almost certainly gives you bytes — do the conversion in exactly one place and put an emoji in your test fixtures, because it is the only thing that distinguishes all four units at once. And never normalise the buffer after positions have been assigned: strip the BOM, translate line endings and expand tabs before lexing or not at all.

advanced

The interesting design pressure is memory against expressiveness. A span on every AST node in a large crate is a substantial fraction of frontend memory, which is why rustc compresses to a 32-bit Span covering a global offset space and spills to an out-of-line table only for spans that carry macro-expansion context. That compression is what makes it affordable to give *every* node a span rather than only the ones a diagnostic currently needs — and giving every node a span is what makes it possible to add a new diagnostic later that points somewhere nobody anticipated. The general principle is worth extracting: information you might need at a boundary you have not built yet is only preserved if preserving it is cheap enough to do unconditionally, so the compression work is what buys the optionality.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specLSP's default position encoding is UTF-16 code units, per the specification, with UTF-8 and UTF-32 negotiable since 3.17 via general.positionEncodings. This is a protocol conformance requirement rather than a convention, and it is unaffected by what the server's host language or the compiler behind it uses internally.
implementationThat rustc, Go and Clang store byte offsets is true of those implementations. rustc additionally interns spans into a 32-bit Span with an out-of-line SpanData for spans carrying macro context, and uses a single global offset space across the crate. Go's token.Pos is likewise a single integer into a FileSet. Frontends written in Java or JavaScript often count UTF-16 natively instead, which changes every claim here about conversion direction.
typicalTab handling has no standard. rustc renders tabs as four spaces and adjusts its underline; GCC exposes -ftabstop=N defaulting to 8. Any underline in a tab-indented file is a guess about the reader's terminal, and the same diagnostic will misalign for some readers no matter what the compiler chooses.

If you were asked this in an interview

  • Where does a compiler store the line number of a token?
  • Your language server puts the squiggle one character to the right, but only sometimes. What do you check first?
  • Give me three defensible answers to "what column is this character in", and say who uses each.

Connections

API Designerror-model
OS & Networkingfiles-and-paths
Domains that do not exist yet
  • Programming Languages & Runtime Internals — String representation and Unicode encodings at runtime
    Which unit a language's string type indexes by — UTF-8 bytes, UTF-16 code units, scalar values — is a runtime representation decision owned there, and it is exactly why a frontend written in Java or JavaScript counts differently from one written in Rust or Go.