Back to Blog

The Go else Keyword: if/else, else if, Early Returns and Scoping

How Go's else keyword works: if/else syntax, why else must share a line with the closing brace, else if chains, if-statement scope, early returns and the missing ternary.

The Go else Keyword: if/else, else if, Early Returns and Scoping

The else keyword runs a block when the if condition before it is false. Write else if to test another condition, and chain as many as you need. The else must sit on the same line as the closing brace of the if block, or the code does not compile. Idiomatic Go uses else less than most languages. When the if block ends in return, the code after the if is already the else case, so Go code returns early and leaves the happy path unindented (Go spec).

TL;DR

  • if cond { ... } else { ... } needs braces around both blocks and no parentheses around the condition.
  • } else { must be on one line. A newline after } ends the if statement, and the compiler reports syntax error: unexpected keyword else, expected }.
  • else if chains are checked top to bottom and the first true condition wins. Long chains read better as a switch with no expression.
  • A variable declared in the if short statement (if v, err := f(); err != nil) is visible in every else if and else branch and gone after the final }.
  • When the if block returns, drop the else. The revive linter flags the leftover form with if block ends with a return statement, so drop this else and outdent its block.
  • Go has no ?: operator. Use if/else, or cmp.Or when you only need a default for a zero value.

How do you write if/else in Go?

An if takes a boolean condition and a block. An else block after it runs when the condition is false. Here is the decision a retry loop makes after a failed request:

example.gogo
package main

import "fmt"

func main() {
	attempt := 3
	maxAttempts := 5

	if attempt < maxAttempts {
		fmt.Printf("attempt %d failed, retrying\n", attempt)
	} else {
		fmt.Printf("attempt %d failed, giving up\n", attempt)
	}
}
example.texttext
attempt 3 failed, retrying

Three rules differ from C, Java and JavaScript:

  1. No parentheses around the condition. if (attempt < maxAttempts) { compiles, but gofmt removes the parentheses.
  2. Braces are required, even for a one-line body. if code >= 500 fmt.Println("server error") fails with syntax error: unexpected name fmt, expected {.
  3. The condition must be a bool. Go has no truthy values, so if len(items) and if user are type errors. Write if len(items) > 0 and if user != nil.

The same brace rule applies to else. A bare statement after else fails with syntax error: else must be followed by if or statement block. Only another if or a { ... } block can follow it. The Go if keyword covers conditions and init statements from the if side.

Why must else be on the same line as }?

Go's grammar uses semicolons to end statements, but you almost never type them. The lexer inserts one at the end of a line when the last token is an identifier, a literal, one of the keywords break, continue, fallthrough or return, an operator like ++, or a closing ), ] or } (Go spec, Semicolons).

That rule is why this fails:

example.gogo
	if retries > 0 {
		fmt.Println("retrying")
	}
	else {
		fmt.Println("giving up")
	}
example.texttext
./main.go:10:2: syntax error: unexpected keyword else, expected }

The line ending in } gets a semicolon, which ends the if statement. The next line then starts a new statement with else, and no statement can start with else. The fix is to put } else { on one line, which is also what gofmt produces. The same rule is why Go puts the opening brace on the same line as the if itself.

C and Java programmers who prefer the Allman style (brace on its own line) run into this in their first week. The upside is that every Go codebase formats if/else the same way, so the style never comes up in review.

How does else if work in Go?

else if is not a separate keyword. It is an else followed by another if statement, which the grammar allows directly: "else" ( IfStmt | Block ). Chain as many as you need. Go checks them top to bottom and runs the first branch whose condition is true.

This loop sorts request latencies into metric buckets:

example.gogo
package main

import (
	"fmt"
	"time"
)

func main() {
	latencies := []time.Duration{
		40 * time.Millisecond,
		250 * time.Millisecond,
		3 * time.Second,
		90 * time.Millisecond,
	}

	var fast, slow, timedOut int
	for _, d := range latencies {
		if d < 100*time.Millisecond {
			fast++
		} else if d < time.Second {
			slow++
		} else {
			timedOut++
		}
	}
	fmt.Printf("fast=%d slow=%d timed_out=%d\n", fast, slow, timedOut)
}
example.texttext
fast=2 slow=1 timed_out=1

Order matters. Swap the first two conditions so d < time.Second comes first, and the output becomes fast=0 slow=3 timed_out=1. Every request under 100ms is also under one second, so the broader check catches it first and the fast branch never runs. Put the narrowest range first.

Once a chain grows past three branches, a switch with no expression usually reads better. Each case is a condition, cases are checked in the same top-to-bottom order, and default replaces the final else:

example.gogo
	for _, d := range latencies {
		switch {
		case d < 100*time.Millisecond:
			fast++
		case d < time.Second:
			slow++
		default:
			timedOut++
		}
	}

The output is the same. See the switch section of the Go keywords guide for how Go's switch differs from C's. switch and select use default for the "nothing else matched" case, so else only ever appears after an if.

What is the scope of a variable declared in an if statement?

An if can start with a short statement, usually a := declaration. The variables it declares are scoped to the whole if statement, which includes every else if and else branch. The spec puts each if in its own implicit block, and the else branches sit inside it (Go spec, Blocks).

This is where else is hard to replace. Checking a PORT value read from the environment needs the parsed number in every branch:

example.gogo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	for _, raw := range []string{"8080", "80", "http"} {
		if port, err := strconv.Atoi(raw); err != nil {
			fmt.Printf("PORT=%q is not a number: %v\n", raw, err)
		} else if port < 1024 {
			fmt.Printf("PORT=%d is privileged (below 1024)\n", port)
		} else {
			fmt.Printf("PORT=%d ok\n", port)
		}
	}
}
example.texttext
PORT=8080 ok
PORT=80 is privileged (below 1024)
PORT="http" is not a number: strconv.Atoi: parsing "http": invalid syntax

port and err are available in all three branches. After the closing brace they no longer exist. Add fmt.Println("listening on", port) after the if and the build fails with undefined: port. If you need the value afterward, declare it on its own line before the if.

The shadowing trap

Because the short statement declares new variables, it can hide an outer variable with the same name. This compiles and passes go vet:

example.gogo
	var cfg Config
	if cfg, err := loadConfig(data); err != nil {
		log.Fatal(err)
	} else {
		fmt.Printf("loaded config, addr=%q\n", cfg.Addr)
	}
	fmt.Printf("starting server, addr=%q\n", cfg.Addr)
example.texttext
loaded config, addr=":8443"
starting server, addr=""

The cfg inside the if is a new variable. The outer cfg stays at its zero value, and the server starts with an empty address. Write cfg, err := loadConfig(data) on its own line and check err with a plain if. The Go var keyword post covers shadowing and the shadow analyzer that catches it.

Why does idiomatic Go avoid else?

Most Go functions are a series of steps that can each fail. If every step nests inside the else of the previous one, the successful path drifts right and the error handling ends up far from the check that caused it. An order handler written that way:

example.gogo
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
		if req.Quantity > 0 {
			if id, err := saveOrder(req); err == nil {
				w.WriteHeader(http.StatusCreated)
				json.NewEncoder(w).Encode(map[string]string{"id": id})
			} else {
				http.Error(w, "could not save order", http.StatusInternalServerError)
			}
		} else {
			http.Error(w, "quantity must be positive", http.StatusBadRequest)
		}
	} else {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
	}
}

To see what happens on invalid JSON, you read to the bottom of the function. Flip each condition so the error case comes first and returns:

example.gogo
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if req.Quantity <= 0 {
		http.Error(w, "quantity must be positive", http.StatusBadRequest)
		return
	}

	id, err := saveOrder(req)
	if err != nil {
		http.Error(w, "could not save order", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"id": id})
}

An httptest run of both versions against four bodies (broken JSON, zero quantity, an out-of-stock SKU and a valid order) gives identical status codes and bodies. The second version has no else at all. Each check sits next to its error response, and the last two lines are the success path at the left margin. Adding a fourth validation step means adding one more if block, not another level of nesting.

The Go team describes this style in Effective Go: "Since error cases tend to end in return statements, the resulting code needs no else statements." The Go Code Review Comments page calls it Indent Error Flow: "Try to keep the normal code path at a minimal indentation, and indent the error handling, dealing with it first."

What linters say about else

The revive linter (the maintained successor to golint) enables two else rules by default. Running it on this code:

example.gogo
func readTimeout() (int, error) {
	secs, err := strconv.Atoi(os.Getenv("TIMEOUT_SECONDS"))
	if err != nil {
		return 0, fmt.Errorf("parse TIMEOUT_SECONDS: %w", err)
	} else {
		return secs, nil
	}
}

func countValid(lines []string) int {
	n := 0
	for _, line := range lines {
		if line == "" {
			continue
		} else {
			n++
		}
	}
	return n
}

reports:

example.texttext
lint.go:13:9: if block ends with a return statement, so drop this else and outdent its block
lint.go:23:10: if block ends with a continue statement, so drop this else and outdent its block

The first comes from indent-error-flow, which fires when the if block ends in return. The second comes from superfluous-else, which covers continue, break and goto and calls that never return, such as panic, os.Exit and log.Fatal. revive also has an opt-in early-return rule for the inverted shape, where the else block is the one that returns. golangci-lint exposes the same rules through its revive linter.

Neither default rule flags the nested handler above, because none of its blocks return. The linter only catches the mechanical case, and flattening a pyramid of success checks is a refactor you have to make yourself.

When is else the right choice in Go?

Dropping else is a rule for blocks that exit. When both branches do real work and neither leaves the function, else is the clearest way to say "one of these two". That happens in three situations.

The first is two outcomes that both continue. A cache lookup counts a hit or a miss and then carries on, and since neither branch returns, there is nothing to outdent:

example.gogo
	if entry, ok := cache[key]; ok {
		hits++
		resp = entry
	} else {
		misses++
		resp = fetchFromOrigin(key)
		cache[key] = resp
	}

The second is choosing between two values. For a cheap value, set the default and override it:

example.gogo
	level := slog.LevelInfo
	if debug {
		level = slog.LevelDebug
	}

When building the default has a cost or a side effect, such as opening a connection, use if/else so only one branch runs:

example.gogo
	var store Store
	if redisURL != "" {
		store = RedisStore{url: redisURL}
	} else {
		store = MemoryStore{}
	}

Default-then-override would mean var store Store = MemoryStore{} followed by a replacement. That only works here because MemoryStore{} costs nothing. If the default opened a file or a connection pool, the override would throw away a resource you never used.

The third is a variable from the short statement. When every branch needs it, as in the PORT example, the else keeps it scoped to the check. Declaring it before the if works too, but then it stays in scope for the rest of the function.

Is there a ternary operator in Go?

No. Go has no cond ? a : b. The Go FAQ explains why: "The reason ?: is absent from Go is that the language's designers had seen the operation used too often to create impenetrably complex expressions. The if-else form, although longer, is unquestionably clearer. A language needs only one conditional control flow construct."

The replacement is if/else or the default-then-override form from the previous section. For the common case of "use this value unless it is empty", Go 1.22 added cmp.Or, which returns the first of its arguments that is not the zero value:

example.gogo
package main

import (
	"cmp"
	"fmt"
	"os"
)

func main() {
	addr := cmp.Or(os.Getenv("LISTEN_ADDR"), ":8080")
	fmt.Println("listening on", addr)
}
example.texttext
listening on :8080

cmp.Or is a function, not an operator, so Go evaluates every argument before the call. cmp.Or(cachedToken, fetchToken()) calls fetchToken every time, even when cachedToken is set. A generic ternary(cond, a, b) helper has the same problem, which is one reason Go codebases rarely define one. When a branch has a cost, write the if.

Where LevelUpGo fits

LevelUpGo teaches Go through exercises that run real Go code in the browser. Go Basics covers if, else if, switch and returning early on errors as part of learning the language from scratch. Simplification has lessons on flattening nested code, simplifying boolean logic and replacing else if chains with switch. For the other 24 reserved words, see Go keywords: all 25 explained.

FAQ

What does else do in Go?

else runs a block when the condition of the if before it is false. It can be followed by a block (else { ... }) or by another if (else if cond { ... }). It is one of Go's 25 reserved keywords and can only appear after an if block.

Why does Go give "syntax error: unexpected keyword else"?

The else is on a new line after the closing } of the if block. Go inserts a semicolon after a } at the end of a line, which ends the if statement, so the else on the next line has nothing to attach to. Write } else { on one line, or run gofmt.

Does Go have elif or elseif?

No. Go writes it as two words, else if, which is an else followed by a new if statement. For long chains, a switch with no expression after the keyword reads more cleanly.

Should I use else after return in Go?

No. If the if block ends with return, the code after the if only runs when the condition was false, so the else adds nothing but indentation. revive flags it with its indent-error-flow rule, and Go Code Review Comments recommends handling the error first and keeping the normal path unindented.

Can I use a variable from the if statement in the else block?

Yes. A variable declared in the short statement, as in if n, err := strconv.Atoi(s); err != nil, is in scope in the if block and in every else if and else branch. It goes out of scope after the final closing brace.

How do I write a one-line if/else in Go?

Not in formatted code. A one-line if/else compiles, but gofmt splits it across lines, and Go has no ternary operator. Use a regular if/else, assign a default and override it in an if, or use cmp.Or when you need a fallback for a zero value.

Sources

Write Go like a senior engineer

Interactive lessons in your browser. The first ones are free.

Try a free lessonOr create a free account