Go · Language design · Tooling

Go's semicolon insertion rule and why braces feel weird

Go looks like a language without semicolons, but its grammar still has them. The lexer quietly inserts most of them before the parser sees the program.

What Go is doing

Go code normally does not show semicolons, but semicolons did not disappear from the language. They moved into the lexer.

The formal Go grammar uses semicolons as statement terminators. Ordinary Go source omits most of them because the lexer inserts them automatically while breaking the program into tokens. By the time parsing happens, the parser can still work with a grammar that has statement terminators.

That distinction matters. This is not quite the same as a parser generously accepting sloppy punctuation. It is a lexical rule: newlines sometimes become semicolons before the parser decides whether the program is syntactically valid.

The newline rule

The rule is intentionally small. When a line ends with a token that can plausibly end a statement, Go inserts a semicolon after that token.

That includes identifiers, basic literals, a few terminating keywords such as return, break, continue, and fallthrough, and closing or postfix tokens such as ++, --, ), ], and }.

So this source:

package main

import "fmt"

func main() {
    fmt.Println("hello")
}

is effectively understood with statement terminators in the places Go's scanner can infer:

package main;

import "fmt";

func main() {
    fmt.Println("hello");
};

Go programmers do not normally write the second version. The point is that the language can keep a compact surface syntax while still having a simple statement boundary rule underneath.

Why the brace goes on the same line

The surprising part appears when a programmer brings brace habits from C, JavaScript, Java, or C# and writes the opening brace on the next line.

func main()
{
    fmt.Println("hello")
}

The token before the newline is ). That is one of the tokens that triggers semicolon insertion. So Go sees something much closer to this:

func main();
{
    fmt.Println("hello");
};

That is not a valid function declaration. The same problem is why Go style keeps the opening brace of if, for, switch, and select on the same line as the control header:

if err != nil {
    return err
}

not:

if err != nil
{
    return err
}

Why this feels weird

It feels weird because Go's visual style looks relaxed: there are almost no semicolons on the page. But the rule is actually strict. Newlines are not always just whitespace. In specific positions, they are syntax.

That creates a small mismatch for programmers coming from languages where brace placement is mostly a style preference. In Go, brace placement is partly style and partly grammar. The common Go layout is not merely the house style chosen by gofmt. It is the layout that fits the language rule.

The upside is consistency. Go avoids an endless argument about brace placement by making one convention line up with the lexer. The downside is that the first encounter can feel arbitrary if nobody explains the semicolon rule.

Where goimports fits

goimports is a formatting and import-maintenance tool. It adds missing imports, removes unused imports, and formats code in the same style as gofmt. It is often used as an editor save hook.

It does not make invalid brace placement valid. If the code is already syntactically broken by semicolon insertion, the formatter cannot reinterpret the program into something else. The source has to obey Go's statement-boundary rule first.

That is why the usual workflow is simple: write ordinary Go shape, let the tool normalize whitespace and imports, and do not fight the brace position.

go install golang.org/x/tools/cmd/goimports@latest
goimports -l -w .

The -l flag lists files whose formatting would change, and -w writes the changes back to disk.

The practical memory

The short version is:

  • Go's grammar still has semicolons.
  • The lexer inserts most semicolons automatically.
  • A newline after ), }, ], identifiers, literals, and some keywords can end a statement.
  • Opening braces for functions and control structures belong on the same line as the header.
  • gofmt and goimports reinforce the normal shape, but they are not a substitute for valid syntax.

Once that rule is internalized, the weirdness mostly disappears. Go is not being vague about punctuation. It is being very specific about where punctuation can be inferred.

Sources

  1. The Go Programming Language Specification: Semicolons.
  2. Effective Go: Semicolons.
  3. goimports command documentation.