Operators #

Operators in Go look familiar to anyone who has written code in C, Java, or Python — but there are several design decisions that make Go different. ++ and -- are statements, not expressions, so you can’t write x = y++. There’s no ternary operator ? :. There’s no ** for exponentiation. There’s the &^ operator (AND NOT) that doesn’t exist in most other languages. And there’s the <- operator, specific to channels, which is at the heart of Go’s concurrency. Understanding Go operators isn’t just memorizing symbols — it’s about understanding why the design is the way it is.

Arithmetic Operators #

Arithmetic operators work on numeric types and follow standard mathematical rules, with a few important caveats.

a := 17
b := 5

fmt.Println(a + b)   // 22 — addition
fmt.Println(a - b)   // 12 — subtraction
fmt.Println(a * b)   // 85 — multiplication
fmt.Println(a / b)   // 3  — INTEGER division (not 3.4!)
fmt.Println(a % b)   // 2  — modulus (remainder)

Integer Division — A Gotcha That Often Surprises #

When both operands are integers, / performs integer division — the result is always truncated toward zero, not rounded down:

fmt.Println(7 / 2)     // 3   (not 3.5)
fmt.Println(-7 / 2)    // -3  (not -4, truncated toward ZERO, not rounded down)
fmt.Println(7 / -2)    // -3

// ANTI-PATTERN: a calculation needing decimals but forgetting to convert
func calcAverage(total, count int) float64 {
    return float64(total / count)  // ✗ integer division first, then conversion
    // total=7, count=2 → 7/2=3 → float64(3) = 3.0  (not 3.5!)
}

// CORRECT: convert to float64 BEFORE dividing
func calcAverage(total, count int) float64 {
    return float64(total) / float64(count)  // ✓ 7.0/2.0 = 3.5
}

For float division, one or both operands must be floats:

fmt.Println(7.0 / 2)     // 3.5
fmt.Println(7 / 2.0)     // 3.5
fmt.Println(float64(7) / float64(2))  // 3.5

Modulus and Negative Values #

The % operator in Go follows this rule: the sign of the result always matches the left operand (the dividend):

fmt.Println( 7 %  3)   //  1
fmt.Println(-7 %  3)   // -1  (negative sign follows -7)
fmt.Println( 7 % -3)   //  1  (positive sign follows 7)
fmt.Println(-7 % -3)   // -1

// A safe even/odd check (works for negative numbers)
func isEven(n int) bool {
    return n%2 == 0  // safe: -4%2 = 0 (even), -3%2 = -1 (odd, non-zero)
}

// If you need an always-positive modulus (like Python):
func positiveMod(a, b int) int {
    return ((a % b) + b) % b
}
fmt.Println(positiveMod(-7, 3))  // 2 (not -1)

The + Operator on Strings #

The + operator also works on strings for concatenation:

firstName := "Budi"
lastName  := "Santoso"
fullName  := firstName + " " + lastName
fmt.Println(fullName)  // "Budi Santoso"

// But remember: every + creates a new string in memory
// For lots of concatenation, use strings.Builder (see the Data Types article)

Comparison Operators #

Comparison operators always produce a bool. All comparison operators work as expected for basic types, but there’s a comparability rule to understand for composite types.

a, b := 10, 20

fmt.Println(a == b)   // false — equal to
fmt.Println(a != b)   // true  — not equal to
fmt.Println(a < b)    // true  — less than
fmt.Println(a > b)    // false — greater than
fmt.Println(a <= b)   // true  — less than or equal to
fmt.Println(a >= b)   // false — greater than or equal to

Comparability — Not All Types Can Be Compared #

// Types that CAN be compared with == and !=:
// int, float, bool, string, pointer, channel, interface, struct (if all its fields are comparable)

// Structs can be compared if all their fields are comparable
type Point struct {
    X, Y int
}
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2)  // true — all fields are equal

// Types that CANNOT be compared with == (compile error):
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// fmt.Println(s1 == s2)  // ← compile error: slice can only be compared to nil
fmt.Println(s1 == nil)    // ✓ slices can only be compared with nil

m := map[string]int{"a": 1}
// fmt.Println(m == map[string]int{"a": 1})  // ← compile error
fmt.Println(m == nil)  // ✓ maps can only be compared with nil

// To compare slices/maps, use reflect.DeepEqual or a manual loop
import "reflect"
fmt.Println(reflect.DeepEqual(s1, s2))  // true

Pointer Comparison #

x := 42
p1 := &x
p2 := &x
p3 := new(int)
*p3 = 42

fmt.Println(p1 == p2)   // true  — both point to the same variable x
fmt.Println(p1 == p3)   // false — point to different memory, even though the values are the same
fmt.Println(*p1 == *p3) // true  — the pointed-to values are equal

String Comparison #

Strings are compared lexicographically — based on byte order:

fmt.Println("apple" == "apple")   // true
fmt.Println("apple" < "banana")   // true  — 'a' < 'b'
fmt.Println("apple" < "Apple")    // false — 'a' (97) > 'A' (65) in ASCII
fmt.Println("abc" < "abd")        // true  — same until 'c' vs 'd'

// Case-insensitive comparison:
import "strings"
fmt.Println(strings.EqualFold("Go", "go"))   // true — case-insensitive
fmt.Println(strings.EqualFold("GO", "go"))   // true

Logical Operators #

Logical operators work on bool values and always produce a bool.

x, y := true, false

fmt.Println(x && y)   // false — AND: both must be true
fmt.Println(x || y)   // true  — OR: one being true is enough
fmt.Println(!x)       // false — NOT: negation

Short-Circuit Evaluation — More Than Just an Optimization #

Short-circuiting is a fundamental property of && and ||: Go does not evaluate the right operand if the result can already be determined from the left operand:

  • false && anything → always false, the right operand is not evaluated
  • true || anything → always true, the right operand is not evaluated

The working principle of short-circuit evaluation on the && and || logical operators can be visualized in the following diagram:

flowchart TD
    subgraph AndEval["AND Evaluation (&&)"]
        A1["Left Condition"] --> CheckA{"Left Result?"}
        CheckA -->|"False"| RetFalse["Immediately false (Right ignored)"]
        CheckA -->|"True"| EvalRightA["Evaluate Right Condition"]
    end

    subgraph OrEval["OR Evaluation (||)"]
        O1["Left Condition"] --> CheckO{"Left Result?"}
        CheckO -->|"True"| RetTrue["Immediately true (Right ignored)"]
        CheckO -->|"False"| EvalRightO["Evaluate Right Condition"]
    end

This isn’t just about performance — it’s a critical safety pattern:

// Pattern 1: nil guard — check nil before dereferencing
var user *User = nil
if user != nil && user.IsAdmin() {
    // user.IsAdmin() won't be called if user == nil
    // without short-circuit, this would panic
    fmt.Println("admin")
}

// Pattern 2: chained validation — stop early if anything fails
func isValidRequest(r *Request) bool {
    return r != nil &&
        len(r.Body) > 0 &&
        len(r.Body) <= MaxBodySize &&
        isValidToken(r.Token)  // only checked if all previous conditions are true
}

// Pattern 3: lazy initialization with ||
func getConfig() *Config {
    return cachedConfig || loadFromDisk()
    // loadFromDisk() is only called if cachedConfig is falsy
}

// Pattern 4: expensive conditions on the right
if isSimpleCheck(x) && isExpensiveCheck(x) {
    // isExpensiveCheck is only called if isSimpleCheck is true
}
Put the condition most likely to be false on the left side of &&, and the condition most likely to be true on the left side of ||. This maximizes the short-circuit benefit — expensive operations on the right only run when truly needed.

Bitwise Operators #

Bitwise operators work on the binary representation of integers. They’re used for bit-level manipulation, permission flags, masking, and certain optimizations.

a := 0b1010  // binary: 1010, decimal: 10
b := 0b1100  // binary: 1100, decimal: 12

Bitwise AND (&) — A Bit Is 1 Only If Both Are 1 #

fmt.Println(a & b)
//   1010
// & 1100
// ------
//   1000  = 8

// Use case: extracting or checking specific bits
const FlagActive = 0b0001
const FlagAdmin  = 0b0010
const FlagVIP    = 0b0100

userFlags := 0b0011  // Active + Admin

fmt.Println(userFlags & FlagActive != 0)  // true  — user is active
fmt.Println(userFlags & FlagAdmin != 0)   // true  — user is admin
fmt.Println(userFlags & FlagVIP != 0)     // false — user is not VIP

Bitwise OR (|) — A Bit Is 1 If Either or Both Are 1 #

fmt.Println(a | b)
//   1010
// | 1100
// ------
//   1110  = 14

// Use case: adding a flag
userFlags |= FlagVIP  // add the VIP flag
fmt.Println(userFlags & FlagVIP != 0)  // true — now VIP

Bitwise XOR (^) — A Bit Is 1 If the Two Differ #

fmt.Println(a ^ b)
//   1010
// ^ 1100
// ------
//   0110  = 6

// Use case: toggling a bit (flipping 0 to 1 and vice versa)
userFlags ^= FlagAdmin  // toggle admin: removed if present, added if absent

Bitwise AND NOT (&^) — Unique to Go #

&^ is an operator rarely found in other languages. It clears bits: a bit in the result is 1 only if the first bit is 1 and the second bit is 0:

fmt.Println(a &^ b)
//   1010
// &^1100
// ------
//   0010  = 2  (bits present in a but NOT in b)

// Use case: removing a specific flag
userFlags &^= FlagAdmin  // remove the Admin flag, without touching other flags
fmt.Println(userFlags & FlagAdmin != 0)  // false — admin has been removed

// In other languages, this is done with: userFlags &= ~FlagAdmin
// In Go, &^ is cleaner and doesn't need the NOT operator (~)

Left Shift (<<) and Right Shift (>>) #

x := 1

fmt.Println(x << 1)  // 2  — multiply by 2
fmt.Println(x << 2)  // 4  — multiply by 4
fmt.Println(x << 3)  // 8  — multiply by 8
fmt.Println(x << 10) // 1024 — 2^10

fmt.Println(16 >> 1)  // 8  — divide by 2
fmt.Println(16 >> 2)  // 4  — divide by 4

// Classic use case: defining size constants
const (
    KB = 1 << 10  // 1024
    MB = 1 << 20  // 1,048,576
    GB = 1 << 30  // 1,073,741,824
)

Right shift on signed integers uses arithmetic shift in Go — the sign bit is propagated. This means -8 >> 1 produces -4, not 2147483644. If you need a logical shift (filling with 0s), use an unsigned type.

fmt.Println(-8 >> 1)          // -4   (signed: arithmetic shift)
fmt.Println(uint(-8) >> 1)    // 9223372036854775804  (unsigned: logical shift)

Assignment Operators #

Go provides compound assignment that combines an operation with assignment:

x := 10

x += 5    // x = x + 5  → 15
x -= 3    // x = x - 3  → 12
x *= 2    // x = x * 2  → 24
x /= 4    // x = x / 4  → 6
x %= 4    // x = x % 4  → 2

// Bitwise compound assignment
flags := 0b1010
flags &= 0b1100   // AND
flags |= 0b0001   // OR
flags ^= 0b0011   // XOR
flags &^= 0b0010  // AND NOT
flags <<= 1       // left shift
flags >>= 1       // right shift

++ and -- — Statements, Not Expressions #

In Go, ++ and -- are statements, not expressions. That means they don’t produce a value and can’t be used inside larger expressions:

i := 5
i++   // ✓ valid — statement
i--   // ✓ valid — statement

// ANTI-PATTERN: all of these are compile errors in Go
// x = i++        // ✗ invalid — ++ is not an expression
// fmt.Println(i++) // ✗ invalid
// j := i++       // ✗ invalid
// ++i            // ✗ invalid — Go doesn't have prefix ++

// There's only one form: postfix, as a standalone statement
for i := 0; i < 5; i++ {  // ✓ in a for statement
    fmt.Println(i)
}

Why? In C and Java, i++ as an expression is a classic source of confusion: x = i++ differs from x = ++i. Go eliminates this ambiguity entirely by making ++ usable only as a standalone statement.


Address and Dereference Operators #

Already covered in the Data Types article, but worth repeating in the operator context:

x := 42

p := &x    // & = "address of" — takes x's memory address, produces a *int
*p = 100   // * = "dereference" — accesses the value at the address p holds

fmt.Println(x)   // 100 — x changed through the pointer
fmt.Println(*p)  // 100 — same as x's value
fmt.Println(p)   // 0xc000... — the memory address

// * is also used in TYPE DECLARATIONS to state "pointer to T"
var q *int = &x   // q is a pointer to int

The Channel Operator (<-) #

The <- operator is specifically for communicating via channels — the heart of Go’s concurrency:

ch := make(chan int, 1)  // buffered channel, capacity 1

// Send a value to the channel (send)
ch <- 42

// Receive a value from the channel (receive)
value := <-ch
fmt.Println(value)  // 42

// Receive with a check whether the channel is still open
value, ok := <-ch
if !ok {
    fmt.Println("channel is closed")
}

// Receive without storing the value (just for synchronization)
<-done  // wait until the done channel receives a value

Operator Precedence #

In an expression with multiple operators, Go follows precedence rules — higher-precedence operators are evaluated first:

Precedence (from highest to lowest):
  5: *   /   %   <<   >>   &   &^
  4: +   -   |   ^
  3: ==  !=  <   <=   >   >=
  2: &&
  1: ||
// Precedence examples
fmt.Println(2 + 3*4)        // 14, not 20  (* is higher than +)
fmt.Println(5 > 3 && 2 < 4) // true (&& is lower than >)
fmt.Println(true || false && false) // true (&& is higher than ||)
//          true || (false && false)
//          true || false
//          true

// If unsure, always use parentheses
// Clearer and doesn't depend on memorizing precedence:
fmt.Println((2 + 3) * 4)           // 20 — explicit
fmt.Println(true || (false && false)) // true — clear
Use parentheses when in doubt. Clear code beats “clever” code that relies on precedence order not everyone has memorized. The compiler doesn’t care — but human readers benefit enormously.

What Go Deliberately Doesn’t Have #

Go intentionally omits several operators that exist in other languages:

// 1. No ternary operator (?:)
// In Java/C: int max = a > b ? a : b;

// ANTI-PATTERN: you can't write this in Go
// max := a > b ? a : b  // ← compile error

// CORRECT: use a plain if-else
max := a
if b > a {
    max = b
}
// Or in a single expression with a function:
func ternary(cond bool, a, b int) int {
    if cond {
        return a
    }
    return b
}

// 2. No exponentiation operator (**)
// In Python: x = 2 ** 10

// CORRECT: use math.Pow
import "math"
x := math.Pow(2, 10)  // 1024.0 (float64)
// For small integer powers, hardcoding or shifting is more efficient:
x2 := 1 << 10  // 1024 (int) — only for powers of 2

// 3. No prefix ++ and --
// In C: ++i  and --i

// Go only has postfix, and only as a statement:
i++  // ✓
// ++i  // ✗ compile error

// 4. No ~ operator (bitwise NOT)
// In C: ~flags

// CORRECT: use XOR with all bits 1
flags := 0b1010
notFlags := flags ^ -1  // XOR with -1 (all bits 1) = NOT
// Or for a specific size:
notFlags8 := ^uint8(flags)  // ^ as a unary operator = bitwise NOT

Idiomatic Patterns with Operators #

Swap Without a Temporary Variable #

a, b := 10, 20
a, b = b, a  // elegant swap — no temp variable needed
fmt.Println(a, b)  // 20 10

Checking Bits with Masking #

// Bit-based permission system
type Perm uint8

const (
    Read    Perm = 1 << iota  // 001
    Write                     // 010
    Execute                   // 100
)

func checkPerm(userPerm, needed Perm) bool {
    return userPerm&needed == needed  // all bits in needed must be in userPerm
}

userPerm := Read | Write  // 011
fmt.Println(checkPerm(userPerm, Read))          // true
fmt.Println(checkPerm(userPerm, Execute))       // false
fmt.Println(checkPerm(userPerm, Read|Write))    // true  — check a combination
fmt.Println(checkPerm(userPerm, Read|Execute))  // false — doesn't have Execute

Alignment and Padding with Bitwise #

// Round n up to the nearest power-of-2 multiple
func alignTo(n, alignment int) int {
    return (n + alignment - 1) &^ (alignment - 1)
}

fmt.Println(alignTo(13, 8))   // 16 — round up to a multiple of 8
fmt.Println(alignTo(16, 8))   // 16 — already a multiple of 8
fmt.Println(alignTo(17, 8))   // 24

Complete Example Program #

The following program simulates a bit-flag-based access control system using various operators:

package main

import (
    "fmt"
    "strings"
)

type Permission uint8

const (
    PermRead    Permission = 1 << iota  // 00000001
    PermWrite                           // 00000010
    PermDelete                          // 00000100
    PermAdmin                           // 00001000
    PermAudit                           // 00010000
)

var permNames = map[Permission]string{
    PermRead:   "Read",
    PermWrite:  "Write",
    PermDelete: "Delete",
    PermAdmin:  "Admin",
    PermAudit:  "Audit",
}

func (p Permission) String() string {
    if p == 0 {
        return "None"
    }
    var parts []string
    for perm, name := range permNames {
        if p&perm != 0 {
            parts = append(parts, name)
        }
    }
    return strings.Join(parts, "|")
}

func (p Permission) Has(perm Permission) bool {
    return p&perm == perm
}

func (p *Permission) Grant(perm Permission) {
    *p |= perm
}

func (p *Permission) Revoke(perm Permission) {
    *p &^= perm
}

func (p *Permission) Toggle(perm Permission) {
    *p ^= perm
}

type User struct {
    Name string
    Perm Permission
}

func main() {
    users := []User{
        {Name: "Alice",  Perm: PermRead | PermWrite},
        {Name: "Bob",    Perm: PermRead},
        {Name: "Carlos", Perm: PermRead | PermWrite | PermDelete | PermAdmin},
    }

    fmt.Println("=== Initial Permission Status ===")
    for _, u := range users {
        fmt.Printf("%-8s → %s\n", u.Name, u.Perm)
    }

    fmt.Println("\n=== Permission Operations ===")

    // The |= operator for granting
    users[1].Perm.Grant(PermWrite)
    fmt.Printf("Bob grant Write   → %s\n", users[1].Perm)

    // The &^= operator for revoking
    users[2].Perm.Revoke(PermAdmin)
    fmt.Printf("Carlos revoke Admin → %s\n", users[2].Perm)

    // The ^= operator for toggling
    users[0].Perm.Toggle(PermDelete)
    fmt.Printf("Alice toggle Delete → %s\n", users[0].Perm)
    users[0].Perm.Toggle(PermDelete)  // toggle again = back to the original
    fmt.Printf("Alice toggle Delete → %s\n", users[0].Perm)

    fmt.Println("\n=== Access Checks ===")
    actions := []struct {
        name string
        perm Permission
    }{
        {"Read file",   PermRead},
        {"Edit file",   PermWrite},
        {"Delete file", PermDelete},
        {"Manage users", PermAdmin},
        {"View audit",  PermAudit},
    }

    for _, u := range users {
        fmt.Printf("\n%s:\n", u.Name)
        for _, action := range actions {
            // The & and == operators for checking
            allowed := u.Perm.Has(action.perm)
            status := "✗"
            if allowed {
                status = "✓"
            }
            fmt.Printf("  %s %-15s\n", status, action.name)
        }
    }

    // Demonstrate arithmetic and comparison operators
    fmt.Println("\n=== Statistics ===")
    totalUsers := len(users)
    adminCount := 0
    for _, u := range users {
        if u.Perm.Has(PermAdmin) {
            adminCount++
        }
    }

    // Percentage calculation — remember: convert to float64 BEFORE dividing
    adminPct := float64(adminCount) / float64(totalUsers) * 100
    fmt.Printf("Total users: %d\n", totalUsers)
    fmt.Printf("Admins: %d (%.1f%%)\n", adminCount, adminPct)
    fmt.Printf("Non-admins: %d\n", totalUsers-adminCount)
}

Summary #

  • Integer division (/) always truncates toward zero — convert to float64 before dividing if you need decimals.
  • Negative modulus (%) follows the left operand’s sign — -7 % 3 = -1, not 2.
  • && and || short-circuit — the right operand isn’t evaluated when the result is already determined; use this for nil guards and chained validation.
  • &^ (AND NOT) is Go’s unique operator for clearing specific bits without touching others.
  • << (left shift) equals multiplying by a power of 2; >> equals dividing by a power of 2.
  • ++ and -- are statements, not expressions — they can’t be used in assignments or as prefixes (++i is invalid).
  • <- is the channel operatorch <- val for sending, val := <-ch for receiving.
  • There’s no ternary ? : in Go — use a more explicit if-else.
  • There’s no ** operator for powers — use math.Pow() or bit shifts for powers of 2.
  • Use parentheses when an expression involves many operators — clearer than relying on memorized precedence.

← Previous: Data Types   Next: Conditional Branching →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact