When the compiler rejects your math
You write if slice1 == slice2 and the build fails. You write 5 / 2 and get 3. You write a + b where a is int and b is int64 and the compiler yells. Go operators look familiar from Python or JavaScript, but the rules are stricter. The compiler refuses to guess your intent. It forces you to be explicit about types, comparisons, and bit manipulation. This strictness prevents silent bugs in production.
Operators are the verbs of the language. They take operands and produce results. Go categorizes them into arithmetic, comparison, logical, and bitwise groups. Each group has specific rules about what types are allowed and what the result means. Understanding these rules is essential for writing correct Go code. The compiler will stop you from making mistakes, but only if you understand why it stops you.
Arithmetic: Precision over convenience
Arithmetic operators perform mathematical calculations. The standard set includes addition +, subtraction -, multiplication *, division /, and modulo %. Go handles these operators with a focus on type safety and predictability.
// CalculateAverage computes the mean of a slice of integers.
func CalculateAverage(values []int) float64 {
// Sum accumulates the total.
var sum int
for _, v := range values {
sum += v
}
// Cast to float64 before division to avoid integer truncation.
// Go does not implicitly convert int to float64.
return float64(sum) / float64(len(values))
}
Integer division truncates the decimal part. If you divide 5 by 2, the result is 2, not 2.5. This behavior matches C and Java, but it surprises developers coming from Python 3 or JavaScript. The compiler enforces this rule strictly. If both operands are integers, the result is an integer. The decimal part is discarded. This design choice prioritizes performance and predictability. You know exactly what type you get back.
Go does not perform implicit type conversions. You cannot add an int and an int64 directly. The compiler rejects the code with mismatched types int and int64 in binary operation. You must cast one of the values explicitly. This rule prevents silent data loss. If the compiler allowed the addition, it would have to decide which type to promote to, potentially losing precision. By forcing you to cast, Go makes the type change visible in the code.
The modulo operator % returns the remainder. The sign of the result follows the dividend. -5 % 3 is -2. This behavior is consistent across platforms. It allows you to wrap indices in circular buffers without worrying about platform-specific quirks.
gofmt enforces spacing around arithmetic operators. You write x = y + z, not x=y+z. The tool decides the formatting. You focus on the logic. This convention keeps code readable and consistent across the community.
Integer division truncates. Cast to float before dividing.
Comparison: Values versus references
Comparison operators test equality or order. The operators are ==, !=, <, >, <=, and >=. They return a boolean value. Go restricts which types can be compared. This restriction prevents subtle bugs related to memory layout and identity.
// User represents a user in the system.
type User struct {
Name string
Age int
}
// CheckUserEquality compares two users by value.
func CheckUserEquality(u1, u2 User) bool {
// Structs are comparable if all fields are comparable.
// String and int are comparable, so User is comparable.
return u1 == u2
}
Structs are comparable if all their fields are comparable. If a struct contains a slice, map, or function, the struct is not comparable. The compiler rejects the code with invalid operation: operator == not defined on struct containing slice. This rule makes sense. Slices are headers pointing to underlying arrays. Comparing two slice headers only tells you if they point to the same array, not if the contents are equal. Go forces you to think about what you are comparing.
Slices, maps, and functions are not comparable with ==. You cannot write if slice1 == slice2. The compiler stops you. To compare slices, you must iterate over the elements and compare them one by one, or use reflect.DeepEqual. The reflection approach is slower and should be used sparingly. It is better to write a custom comparison function for your specific use case.
The if err != nil pattern is the most common comparison in Go. It checks if an error value is not the zero value. The community accepts the boilerplate because it makes the error handling path visible. You cannot hide errors behind exceptions. You must check them explicitly. This convention improves code reliability.
Slices are not values. Compare contents, not headers.
Logical: Short-circuiting as a tool
Logical operators combine boolean values. The operators are && (AND), || (OR), and ! (NOT). They support short-circuit evaluation. This feature allows you to write safe and efficient conditions.
// Validate checks the context and input.
func Validate(ctx context.Context, input string) error {
// Short-circuiting saves work.
// If ctx.Err() is not nil, the right side is never evaluated.
if ctx.Err() != nil || input == "" {
return fmt.Errorf("invalid request")
}
// Context is always the first parameter.
// Functions should respect cancellation.
return nil
}
Short-circuit evaluation means the second operand is only evaluated if the first operand does not determine the result. For &&, if the left side is false, the right side is skipped. For ||, if the left side is true, the right side is skipped. This behavior is essential for nil checks and context cancellation. You can write if ptr != nil && ptr.Value > 0 without worrying about a nil pointer dereference. The compiler guarantees the order of evaluation.
Logical operators return booleans. They do not return the value of the operand like JavaScript's || operator. In JavaScript, a || b returns a if it is truthy, otherwise b. In Go, a || b always returns true or false. This purity makes the code easier to reason about. You know the result is a boolean. You do not have to guess the type.
The context.Context type is a convention in Go. It carries deadlines, cancellation signals, and request-scoped values. Functions that take a context should respect cancellation. The short-circuiting behavior of logical operators makes it easy to check ctx.Err() before doing expensive work.
Short-circuiting is a feature. Write conditions that depend on order.
Bitwise: The power of &^
Bitwise operators manipulate individual bits. The operators are & (AND), | (OR), ^ (XOR), << (left shift), >> (right shift), and &^ (AND NOT). They are useful for flags, permissions, and low-level data packing.
const (
// FlagRead represents the read permission.
FlagRead = 1 << iota
// FlagWrite represents the write permission.
FlagWrite
// FlagExec represents the execute permission.
FlagExec
)
// HasPermission checks if a permission flag is set.
func HasPermission(perm, flag uint) bool {
// Bitwise AND isolates the specific flag bit.
// If the result is non-zero, the flag is set.
return (perm & flag) != 0
}
// RemovePermission clears a permission flag.
func RemovePermission(perm, flag uint) uint {
// Go's unique AND-NOT operator clears bits.
// It is more readable than perm &^ flag.
return perm &^ flag
}
The iota constant generator is the convention for defining flags. It produces a sequence of integers starting from zero. Shifting 1 by iota creates powers of two. This pattern ensures each flag has a unique bit position. You can combine flags with | and check them with &.
The &^ operator is unique to Go. It performs a bitwise AND NOT operation. a &^ b clears the bits in a that are set in b. In other languages, you would write a & ~b. Go's version is more readable. It clearly expresses the intent to clear bits. This operator is essential for removing flags from a bitmask.
Shift operators << and >> move bits left or right. Left shift multiplies by powers of two. Right shift divides by powers of two. They are useful for packing multiple values into a single integer. For example, you can pack a date into an integer by shifting the year, month, and day into different bit fields.
Use &^ to clear bits. It reads better than & ~.
Pitfalls and compiler errors
Go operators have specific rules that can trip up developers. Understanding these pitfalls helps you write correct code.
Integer division truncates. If you expect a float, you must cast. The compiler does not warn you about truncation. It assumes you know what you are doing. This is a common source of bugs. Always cast to float before dividing if you need decimal precision.
Type mismatches are forbidden. You cannot mix int and int64 in arithmetic operations. The compiler rejects the code with mismatched types int and int64 in binary operation. You must cast one of the values. This rule prevents silent data loss.
Slice comparison is forbidden. You cannot use == on slices. The compiler rejects the code with invalid operation: operator == not defined on slice. You must compare elements manually or use reflection. This rule prevents bugs related to slice identity versus equality.
Operator precedence can be surprising. Shift operators << and >> bind tighter than addition + and subtraction -. The expression 1 << 2 + 3 is parsed as 1 << (2 + 3), which is 32. Not (1 << 2) + 3, which is 7. Use parentheses to make the order explicit. The compiler follows standard precedence rules, but explicit parentheses improve readability.
Modulo with negative numbers follows the dividend. -5 % 3 is -2. This behavior is consistent, but it can be unexpected if you assume the result is always positive. Use math.Abs if you need a positive remainder.
Trust the compiler on types. Cast explicitly.
Decision matrix
Use arithmetic operators when you need mathematical calculations on numeric types. Use comparison operators when you need to test equality or order between comparable values. Use logical operators when you need to combine boolean conditions with short-circuit evaluation. Use bitwise operators when you need to manipulate individual bits for flags or low-level data packing.