Back to Blog

The Go if Keyword: Conditions, Init Statements and Early Returns

How Go's if keyword works: required braces, bool-only conditions, else placement, init statements, shadowing, early returns and why Go has no ternary.

The Go if Keyword: Conditions, Init Statements and Early Returns

The if keyword runs a block of code when a boolean condition is true. Go's version has three rules that surprise people coming from C, JavaScript or Python. The condition takes no parentheses, the braces are always required, and the condition must be a real bool, because Go has no truthy or falsy values. An if can also start with a short statement, which is why if err := f(); err != nil appears in almost every Go file (Go spec).

TL;DR

  • if status >= 500 { ... } has no parentheses around the condition, and the braces are required even for one line.
  • The condition must have type bool. if retries { ... } fails with non-boolean condition in if statement.
  • else must go on the same line as the closing }. On its own line it is a syntax error.
  • if v, err := parse(s); err != nil { ... } declares variables that exist in the if and all its else branches, and nowhere after.
  • := inside an if block can shadow an outer variable. The code compiles and silently uses the old value.
  • Idiomatic Go returns early on errors and keeps the happy path unindented. An else after a return is usually removed.
  • Go has no ternary operator. Use an assignment plus an if, cmp.Or for defaults, or min and max for clamping.
  • && and || short-circuit, so req.User != nil && req.User.IsAdmin never dereferences a nil pointer.
  • A long if/else if chain usually reads better as a switch with no expression.

How do you write an if statement in Go?

Write if, a boolean expression and a block in braces. Here is a retry check for an HTTP client:

example.gogo
package main

import "fmt"

func shouldRetry(status, attempt, maxAttempts int) bool {
	if attempt >= maxAttempts {
		return false
	}
	return status == 429 || status >= 500
}

func main() {
	fmt.Println(shouldRetry(503, 1, 3))
	fmt.Println(shouldRetry(503, 3, 3))
	fmt.Println(shouldRetry(404, 1, 3))
}
example.texttext
true
false
false

There are no parentheses around attempt >= maxAttempts. You can write them and the code still compiles, but gofmt removes them the next time the file is saved. The braces are the part Go insists on. if attempt >= maxAttempts return false on one line doesn't parse, and neither does a condition followed by a statement on the next line:

example.gogo
if status >= 500
	fmt.Println("server error")
example.texttext
./main.go:8:19: syntax error: unexpected newline, expected { after if clause

Required braces rule out a common C bug. You add a second line under a braceless if, it looks like part of the condition, and it runs every time. Apple's 2014 "goto fail" TLS bug was that mistake. In Go every if body has explicit boundaries, and gofmt indents them the same way in every codebase.

Does Go have truthy and falsy values?

No. The condition must be an expression of type bool. An int, a string, a pointer, a slice or an error is never converted to true or false for you:

example.gogo
retries := 3
if retries {
	fmt.Println("retrying")
}
example.texttext
./main.go:7:5: non-boolean condition in if statement

You write the comparison you mean: retries > 0, name != "", user != nil, len(items) > 0 or err != nil. It costs a few characters and makes the check explicit. In JavaScript, if (count) skips the block when count is 0, which is often a bug when zero is a valid value. In Go the reader always sees which condition is tested.

How do else and else if work in Go?

else runs when the condition is false, and else if tests another condition. Here a request logger picks a log level from the status code:

example.gogo
func logRequest(ctx context.Context, logger *slog.Logger, path string, status int) {
	var level slog.Level
	if status >= 500 {
		level = slog.LevelError
	} else if status >= 400 {
		level = slog.LevelWarn
	} else {
		level = slog.LevelInfo
	}
	logger.Log(ctx, level, "request", "path", path, "status", status)
}
example.texttext
level=INFO msg=request path=/api/orders status=200
level=WARN msg=request path=/api/orders/99 status=404
level=ERROR msg=request path=/api/checkout status=502

The branches are checked from top to bottom and the first true one runs. A 502 matches status >= 500 first, so it never reaches the status >= 400 test. The Go else keyword covers else and else if chains in more detail.

Why must else be on the same line as the closing brace?

Because of automatic semicolon insertion. Go's grammar uses semicolons to end statements, and the lexer adds one at the end of a line that ends with }, an identifier, a literal or a few other tokens (Go spec). A } on a line by itself therefore ends the if statement. The else on the next line then starts a new statement, and no statement can begin with else:

example.gogo
if status >= 500 {
	fmt.Println("server error")
}
else {
	fmt.Println("ok")
}
example.texttext
./main.go:10:2: syntax error: unexpected keyword else, expected }

The fix is } else { on one line. The same rule explains why the opening brace of an if, for or func can't go on its own line. It also means Go projects don't argue about brace style. The compiler accepts one placement, and gofmt handles the rest of the formatting.

What is the if statement with a short statement?

An if can start with a short statement, separated from the condition by a semicolon. The statement runs first, and any variables it declares are scoped to the if. The most common use is error handling:

example.gogo
func loadConfig(path string) (Config, error) {
	var cfg Config
	data, err := os.ReadFile(path)
	if err != nil {
		return cfg, fmt.Errorf("read config: %w", err)
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return cfg, fmt.Errorf("parse %s: %w", path, err)
	}
	return cfg, nil
}

json.Unmarshal only returns an error, so the call and the check fit on one line, and that err stops existing after the closing brace. os.ReadFile returns data you need afterwards, so it gets its own line. That is the usual split in Go code. Put the statement inside the if when its results are only needed for the check, and outside when the rest of the function uses them.

The same form works with every expression that returns a value and a boolean, which Go calls the comma-ok idiom. A map lookup tells you whether the key was present:

example.gogo
roles := map[string]string{"u_42": "admin", "u_7": "billing"}

if role, ok := roles["u_42"]; ok {
	fmt.Println("u_42 is", role)
}
if _, ok := roles["u_99"]; !ok {
	fmt.Println("u_99 has no role")
}
example.texttext
u_42 is admin
u_99 has no role

A type assertion checks for optional behavior without risking a panic. A streaming handler flushes only when the http.ResponseWriter supports it:

example.gogo
if f, ok := w.(http.Flusher); ok {
	f.Flush()
}

Go 1.26 added errors.AsType, a generic version of errors.As that returns the matched error and a boolean. That makes it fit the same pattern. A service can fall back to defaults when the config file is missing, but still fail on a broken one:

example.gogo
for _, path := range []string{"missing.json", "bad.json"} {
	_, err := loadConfig(path)
	if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
		fmt.Println("no config file at", pathErr.Path, "so using defaults")
	} else if err != nil {
		fmt.Println("fatal:", err)
	}
}
example.texttext
no config file at missing.json so using defaults
fatal: parse bad.json: invalid character '}' looking for beginning of object key string

With the older errors.As, you declare var pathErr *fs.PathError before the if and pass &pathErr, so the variable outlives the check.

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

The variable exists in the condition, the if block and every else if and else block attached to it. It doesn't exist after the statement ends (Go spec):

example.gogo
raw := "abc"
if n, err := strconv.Atoi(raw); err != nil {
	fmt.Println("bad MAX_CONNS:", err)
} else {
	fmt.Println("max conns", n)
}
fmt.Println(n)

The else branch can use n, but the last line fails with undefined: n. That is deliberate. The variable can't leak into code that shouldn't depend on it, and the name is free again for the next if. It's how one Go function can have ten if err := ...; err != nil checks without ten different error names.

How does := inside an if cause shadowing bugs?

Every block opens a new scope, and := always declares in the current scope. When you assign to an outer variable from inside an if block with :=, you get a new variable with the same name instead:

example.gogo
func requestTimeout() time.Duration {
	timeout := 5 * time.Second
	if raw := os.Getenv("HTTP_TIMEOUT"); raw != "" {
		timeout, err := time.ParseDuration(raw)
		if err != nil {
			fmt.Println("ignoring HTTP_TIMEOUT:", err)
			return 5 * time.Second
		}
		fmt.Println("HTTP_TIMEOUT set to", timeout)
	}
	return timeout
}
example.texttext
HTTP_TIMEOUT set to 30s
using 5s

err is new inside the block, so := is legal, and it quietly declares a second timeout as well. The parsed value goes into the inner variable and disappears at the closing brace. The compiler accepts this and the default go vet checks don't flag it. The fix is to declare err with var err error and use =, so the outer timeout is assigned. The Go var keyword covers shadowing in more depth, including the shadow analyzer that catches this.

Why does idiomatic Go avoid else?

Because most else branches in Go follow an if that already returned. Effective Go puts it this way: when an if statement doesn't flow into the next statement, because the body ends in break, continue, goto or return, the unnecessary else is omitted (Effective Go). The result is code where errors are handled and returned as they come up, and the successful path runs straight down the left edge of the function.

Here is a refund handler written with a nested if for every check:

example.gogo
func handleRefund(w http.ResponseWriter, r *http.Request) {
	user, ok := userFromContext(r.Context())
	if ok {
		if user.CanRefund {
			var req RefundRequest
			if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
				if req.AmountCents > 0 {
					fmt.Fprintf(w, "refund of %d cents queued for %s", req.AmountCents, req.OrderID)
				} else {
					http.Error(w, "amount_cents must be positive", http.StatusBadRequest)
				}
			} else {
				http.Error(w, "invalid JSON body", http.StatusBadRequest)
			}
		} else {
			http.Error(w, "forbidden", http.StatusForbidden)
		}
	} else {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
	}
}

It works, but the actual refund sits five levels deep, and each error message is far from the condition that caused it. To see why a request gets a 401, you have to match the last else to the first if. Inverting each condition and returning early gives the same behavior:

example.gogo
func handleRefund(w http.ResponseWriter, r *http.Request) {
	user, ok := userFromContext(r.Context())
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	if !user.CanRefund {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}

	var req RefundRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if req.AmountCents <= 0 {
		http.Error(w, "amount_cents must be positive", http.StatusBadRequest)
		return
	}

	fmt.Fprintf(w, "refund of %d cents queued for %s", req.AmountCents, req.OrderID)
}

Running both versions through httptest with the same five requests gives identical responses:

example.texttext
401 unauthorized
403 forbidden
400 invalid JSON body
400 amount_cents must be positive
200 refund of 4999 cents queued for ord_1001

Each check in the flat version is a guard clause, a condition paired directly with its response. Adding a new rule, such as a refund limit, means adding one more if block above the last line instead of wrapping everything in another level.

What does "drop this else and outdent its block" mean?

It is the message from the indent-error-flow rule in revive, the linter that replaced golint and runs inside golangci-lint. It fires on an if block that ends in return followed by an else:

example.gogo
func parsePort(raw string) (int, error) {
	if port, err := strconv.Atoi(raw); err != nil {
		return 0, fmt.Errorf("invalid port %q: %w", raw, err)
	} else if port < 1 || port > 65535 {
		return 0, errors.New("port out of range")
	} else {
		return port, nil
	}
}
example.texttext
main.go:14:9: if block ends with a return statement, so drop this else and outdent its block (move short variable declaration to its own line if necessary)

The hint in parentheses is about if scoping. port is declared in the if statement, so removing the else would put return port, nil outside its scope. The fix is to move port, err := strconv.Atoi(raw) to its own line above the if, and then each check becomes a plain guard clause that returns.

revive's superfluous-else rule covers the same pattern after break, continue, goto, panic and os.Exit, and the optional early-return rule suggests inverting an if/else whose else branch ends in return. None of this means else is wrong. The logRequest example above uses it correctly, because every branch assigns a value and execution continues after the statement. The Go else keyword covers when else is the right choice and how to refactor the cases where it isn't.

Does Go have a ternary operator?

No. The Go FAQ answers this directly. The designers had seen ?: "used too often to create impenetrably complex expressions", and they decided that "a language needs only one conditional control flow construct" (Go FAQ). The FAQ's replacement is an if that assigns a variable. In practice the shortest form sets the default first and overrides it:

example.gogo
timeout := 5 * time.Second
if cfg.Debug {
	timeout = 5 * time.Minute
}

Two common ternary uses have dedicated helpers now. cmp.Or, added in Go 1.22, returns the first argument that isn't the zero value, which covers most "use this or fall back to that" cases. The min and max builtins from Go 1.21 cover clamping:

example.gogo
port := cmp.Or(os.Getenv("PORT"), "8080")
fmt.Println("listening on :" + port)

requested := 500
pageSize := max(1, min(requested, 100))
fmt.Println("page size", pageSize)
example.texttext
listening on :8080
page size 100

cmp.Or takes any number of arguments, so cmp.Or(flagAddr, os.Getenv("ADDR"), ":8080") checks a flag, then the environment, then a default.

Why not write a generic ternary function?

You can write func ternary[T any](cond bool, a, b T) T, and some codebases do. It has a trap that the if form doesn't. Go evaluates every argument before calling a function, so both branches always run:

example.gogo
var u *User
name := ternary(u != nil, u.Name, "anonymous")
example.texttext
panic: runtime error: invalid memory address or nil pointer dereference

A real ?: operator would skip u.Name when u is nil. The function reads u.Name first and crashes. The same goes for expensive calls, which run whether or not their result is used. An if evaluates only the branch it takes.

How do && and || short-circuit in an if?

&& evaluates its right side only when the left side is true, and || evaluates its right side only when the left side is false (Go spec). So the order of the conditions can decide whether the code panics. A nil check has to come before the field access:

example.gogo
if req.User != nil && req.User.IsAdmin {
	fmt.Println("admin panel")
} else {
	fmt.Println("access denied")
}

With a nil req.User, this prints access denied. Swap the two conditions, as in req.User.IsAdmin && req.User != nil, and the same request panics with invalid memory address or nil pointer dereference, because the field is read before the check runs.

|| works the same way for early rejection. This helper parses an Authorization header and stops at the first condition that fails:

example.gogo
func bearerToken(header string) (string, bool) {
	scheme, token, found := strings.Cut(header, " ")
	if !found || !strings.EqualFold(scheme, "Bearer") || token == "" {
		return "", false
	}
	return token, true
}

For "Bearer eyJhbGciOi" it returns the token and true. For "Basic dXNlcjpwYXNz" and for a bare "Bearer" it returns false. Put cheap checks first and slow ones, such as a database lookup, last, so the slow call only runs when it can change the result. && binds tighter than ||, so a || b && c means a || (b && c). Add parentheses when you mix them, since gofmt keeps those.

When should you use switch instead of if/else if?

When you compare one value against several cases, or when a chain has more than two or three else if branches. A switch with no expression tests each case as a boolean from top to bottom, exactly like an if/else if chain:

example.gogo
switch {
case status >= 500:
	level = slog.LevelError
case status >= 400:
	level = slog.LevelWarn
default:
	level = slog.LevelInfo
}

The conditions line up in one column, and there is no break, because a Go switch stops after the first matching case. A switch also accepts a short statement, as in switch ext := filepath.Ext(name); ext { ... }, with the same scoping rules as if. Keep if for one or two conditions, and especially for error checks, where if err != nil is what every Go reader expects to see. The switch section of the Go keywords guide covers expression switches, type switches and fallthrough.

Where LevelUpGo fits

LevelUpGo teaches Go through exercises that run real Go code in the browser. Go Basics covers if, else and the logical operators early on, with exercises that make you write the conditions yourself. Simplification has lessons on flattening nested code with early returns, simplifying boolean logic and replacing long if chains with a switch. The Training Ground has short standalone exercises for practicing outside a course. For the other 24 reserved words, see Go keywords: all 25 explained.

FAQ

Does Go have a ternary operator?

No. Go has no ?: operator, and the Go FAQ says the designers left it out because it was often used to build hard-to-read expressions. Use a variable with a default and an if that overrides it. For fallback values, cmp.Or(a, b) returns the first non-zero argument, and min and max handle clamping.

Why does Go require braces on if?

Braces make the extent of every if body explicit, so adding a line can't accidentally change which statements the condition controls. They also let Go drop the parentheses around the condition, since the { marks where the condition ends. gofmt then formats every if the same way.

Does Go have truthy and falsy values?

No. An if condition must be of type bool. Numbers, strings, pointers, slices and errors are never converted automatically, and if count { ... } fails with non-boolean condition in if statement. Write the comparison explicitly, such as count > 0, s != "" or err != nil.

Can an if statement declare a variable in Go?

Yes. An if can start with a short statement, as in if err := save(order); err != nil. Variables declared there are in scope for the condition, the if block and any else if or else blocks, and they disappear after the statement ends.

Why must else be on the same line as the closing brace in Go?

Go's lexer inserts a semicolon at the end of a line that ends with }. If else starts the next line, the if statement has already ended, and the compiler reports syntax error: unexpected keyword else, expected }. Writing } else { on one line avoids the inserted semicolon.

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