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.