Skip to main content

Conditionals

What are Conditionals?

Conditionals let your program make decisions — executing different code paths depending on whether a condition is true or false. Go's if statement is intentionally simple: no parentheses around the condition, but curly braces are always required, even for single-line bodies. This enforced consistency eliminates an entire class of formatting debates and bugs.

Go adds one powerful twist: the initialization statement. You can declare and initialize a variable in the if header itself, scoping it tightly to the if-else block. This pattern is idiomatic Go and appears everywhere in the standard library.


Basic if-else Syntax

package main

import "fmt"

func main() {
temperature := 22

if temperature > 30 {
fmt.Println("It's hot outside.")
} else if temperature > 20 {
fmt.Println("Nice and comfortable.")
} else if temperature > 10 {
fmt.Println("A bit chilly — grab a jacket.")
} else {
fmt.Println("It's cold outside.")
}
}

Output:

Nice and comfortable.

Key rules:

  • No parentheses around the condition.
  • Opening brace { must be on the same line as if or else.
  • else must appear on the same line as the closing } of the previous block.

Initialization Statement

The most distinctive Go idiom is the if statement with an initialization clause:

if <init statement>; <condition> {
// ...
}

The variable declared in the init statement is visible only within the if-else chain — it goes out of scope when the block ends.

package main

import (
"fmt"
"strconv"
)

func main() {
input := "42"

// n is scoped to this if-else block only
if n, err := strconv.Atoi(input); err != nil {
fmt.Println("Parse error:", err)
} else {
fmt.Printf("Parsed integer: %d (doubled: %d)\n", n, n*2)
}

// fmt.Println(n) // compile error: n is undefined here

// Another common use: map lookup
scores := map[string]int{"Alice": 95, "Bob": 72}

if score, ok := scores["Alice"]; ok {
fmt.Println("Alice's score:", score)
} else {
fmt.Println("Alice not found")
}

if score, ok := scores["Charlie"]; ok {
fmt.Println("Charlie's score:", score)
} else {
fmt.Println("Charlie not found (zero value would be:", score, ")")
}
}

Output:

Parsed integer: 42 (doubled: 84)
Alice's score: 95
Charlie not found (zero value would be: 0 )

Why this matters: without the init statement you would pollute the outer scope with a variable (err or ok) that is only relevant inside the conditional. The init form keeps scope tight and intent clear.


Nested if vs Early Return

Deeply nested if statements are hard to read. Go strongly favors the early return pattern (also called "guard clauses"), which flattens the code and puts the error-handling logic at the top, leaving the happy path at the bottom with minimal indentation.

Nested (avoid this):

package main

import (
"errors"
"fmt"
)

func processNested(value int) error {
if value >= 0 {
if value <= 100 {
if value%2 == 0 {
fmt.Println("Valid even value:", value)
return nil
} else {
return errors.New("value must be even")
}
} else {
return errors.New("value exceeds maximum of 100")
}
} else {
return errors.New("value must be non-negative")
}
}

func main() {
fmt.Println(processNested(42))
fmt.Println(processNested(-1))
fmt.Println(processNested(101))
fmt.Println(processNested(7))
}

Output:

Valid even value: 42
<nil>
value must be non-negative
value exceeds maximum of 100
value must be even

Early return (idiomatic Go):

package main

import (
"errors"
"fmt"
)

func processEarly(value int) error {
if value < 0 {
return errors.New("value must be non-negative")
}
if value > 100 {
return errors.New("value exceeds maximum of 100")
}
if value%2 != 0 {
return errors.New("value must be even")
}

fmt.Println("Valid even value:", value)
return nil
}

func main() {
fmt.Println(processEarly(42))
fmt.Println(processEarly(-1))
fmt.Println(processEarly(101))
fmt.Println(processEarly(7))
}

Output:

Valid even value: 42
<nil>
value must be non-negative
value exceeds maximum of 100
value must be even

The early-return version reads top-to-bottom: each guard eliminates a bad case immediately, and the successful logic appears last with no indentation debt.


Practical Example: HTTP Status Code Branching

The following program simulates an HTTP client that inspects the response status code and takes different actions for different code ranges.

package main

import (
"fmt"
"net/http"
"time"
)

type RequestResult struct {
URL string
StatusCode int
Body string
Err error
}

func classifyStatus(result RequestResult) {
if result.Err != nil {
fmt.Printf("[ERROR] Request to %s failed: %v\n", result.URL, result.Err)
return
}

code := result.StatusCode

if code >= 200 && code < 300 {
fmt.Printf("[OK %d] %s — success\n", code, result.URL)
} else if code >= 300 && code < 400 {
fmt.Printf("[REDIRECT %d] %s — follow the Location header\n", code, result.URL)
} else if code == 401 {
fmt.Printf("[AUTH %d] %s — authentication required\n", code, result.URL)
} else if code == 403 {
fmt.Printf("[FORBIDDEN %d] %s — access denied\n", code, result.URL)
} else if code == 404 {
fmt.Printf("[NOT FOUND %d] %s — resource does not exist\n", code, result.URL)
} else if code == 429 {
fmt.Printf("[RATE LIMIT %d] %s — slow down\n", code, result.URL)
} else if code >= 400 && code < 500 {
fmt.Printf("[CLIENT ERROR %d] %s — bad request\n", code, result.URL)
} else if code >= 500 {
fmt.Printf("[SERVER ERROR %d] %s — retry later\n", code, result.URL)
} else {
fmt.Printf("[UNKNOWN %d] %s\n", code, result.URL)
}
}

func fetchURL(url string) RequestResult {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return RequestResult{URL: url, Err: err}
}
defer resp.Body.Close()
return RequestResult{URL: url, StatusCode: resp.StatusCode}
}

func main() {
// Simulated results (avoids live network calls in this example)
results := []RequestResult{
{URL: "https://example.com/api/data", StatusCode: 200},
{URL: "https://example.com/old-page", StatusCode: 301},
{URL: "https://example.com/admin", StatusCode: 403},
{URL: "https://example.com/missing", StatusCode: 404},
{URL: "https://example.com/api", StatusCode: 429},
{URL: "https://example.com/broken", StatusCode: 500},
{URL: "https://unreachable.invalid", Err: fmt.Errorf("dial tcp: no such host")},
}

for _, result := range results {
classifyStatus(result)
}
}

Output:

[OK 200] https://example.com/api/data — success
[REDIRECT 301] https://example.com/old-page — follow the Location header
[FORBIDDEN 403] https://example.com/admin — access denied
[NOT FOUND 404] https://example.com/missing — resource does not exist
[RATE LIMIT 429] https://example.com/api — slow down
[SERVER ERROR 500] https://example.com/broken — retry later
[ERROR] Request to https://unreachable.invalid failed: dial tcp: no such host

Error Handling Patterns

Go functions typically return (value, error). The canonical handling pattern combines the initialization statement with an if err != nil check.

package main

import (
"errors"
"fmt"
"os"
"strconv"
)

// Sentinel errors — exported so callers can compare with errors.Is
var (
ErrNegativeInput = errors.New("input must be non-negative")
ErrOverflow = errors.New("input exceeds allowed maximum")
)

func safeSqrt(input string) (float64, error) {
// Init statement scopes n to this block
if n, err := strconv.ParseFloat(input, 64); err != nil {
return 0, fmt.Errorf("invalid number %q: %w", input, err)
} else if n < 0 {
return 0, ErrNegativeInput
} else if n > 1e15 {
return 0, ErrOverflow
} else {
// Manually implement integer square root for demonstration
if n == 0 {
return 0, nil
}
// Newton's method approximation
x := n
for {
next := (x + n/x) / 2
if next >= x {
return x, nil
}
x = next
}
}
}

func main() {
inputs := []string{"144", "-9", "abc", "2e16", "0", "2"}

for _, input := range inputs {
if result, err := safeSqrt(input); err != nil {
if errors.Is(err, ErrNegativeInput) {
fmt.Fprintf(os.Stderr, "DOMAIN ERROR for %q: %v\n", input, err)
} else if errors.Is(err, ErrOverflow) {
fmt.Fprintf(os.Stderr, "OVERFLOW for %q: %v\n", input, err)
} else {
fmt.Fprintf(os.Stderr, "PARSE ERROR for %q: %v\n", input, err)
}
} else {
fmt.Printf("sqrt(%s) ≈ %.6f\n", input, result)
}
}
}

Output:

sqrt(144) ≈ 12.000000
DOMAIN ERROR for "-9": input must be non-negative
PARSE ERROR for "abc": invalid number "abc": strconv.ParseFloat: parsing "abc": invalid syntax
OVERFLOW for "2e16": input exceeds allowed maximum
sqrt(0) ≈ 0.000000
sqrt(2) ≈ 1.414214

Expert Tips

Prefer the init statement for one-shot variables. Any variable whose lifetime should be bounded to the if-else block belongs in the init statement. This prevents accidental re-use and makes the intent self-documenting.

errors.Is and errors.As over ==. When a Go function wraps errors with %w, direct equality comparison (err == ErrFoo) fails. Always use errors.Is(err, ErrFoo) to unwrap through error chains. Use errors.As(err, &target) when you need to inspect fields of a custom error type.

Avoid boolean flag variables. If you find yourself writing found := false followed by a loop and then if found { ... }, restructure: use early return or extract a helper function. Flag variables are a code smell in Go.

Nil is a valid zero value for interfaces and pointers. An if x != nil guard is necessary before calling methods on an interface or dereferencing a pointer. However, note that an interface value is nil only when both its type and value are nil — a typed nil pointer assigned to an interface is not nil.