1. Bits, flags, and masks
If until now you have lived happily with +, -, *, and if, then bitwise operators can initially look like “the secret language of ancient gophers”. In reality, it is just another view of integers: a number can be treated not only as “forty-two”, but also as a set of tiny switches (bits) — on/off.
This approach is especially useful when you need to store multiple independent features in one number and quickly check what is enabled.
The most common practical scenario is flags: a set of options, permissions, modes. For example, a user may have read, write, execute rights. You can store three separate bool values, but sometimes it is more convenient to store everything as a single number and manipulate bits.
Binary notation right in code: 0b1100 and readable masks
Imagine you have a panel of light bulbs. Each bulb is one bit: 0 means “off”, 1 means “on”. An integer is stored in memory as a sequence of such bits. For example, the number 13 in binary is 1101.
In Go, binary representation is not only “to see what a number looks like”, but also a way to write masks directly in code so they can be read by eye.
For this, numeric literals have prefixes:
- 0b... — binary (best for flags and bit fields)
- 0x... — hex (often in protocols/bytes)
- 0o... — octal (rarer, but sometimes seen)
- no prefix — decimal
All of these notations are equivalent:
const (
a = 12 // decimal
b = 0b1100 // binary
c = 0x0C // hex
d = 0o14 // octal
)
Why 0b... is so convenient for flags: you immediately see which bits are enabled, not just the number “12”. For example, 0b0101 means “bits 0 and 2 are enabled”.
So that long masks do not turn into a mess, you can place _ in numbers to group bits (the value does not change):
const mask = 0b1111_0000_0011_0101
This way your eye sees groups (like bytes/nibbles), and the code becomes faster to read.
Word of the day: mask. A mask is a number where we care about certain bits. Often a mask contains exactly one enabled bit (for example, 00010000), and then it represents “one flag”.
To avoid eye strain, we will actively use %b formatting in fmt.Printf, which prints a number in binary (that is, “as light bulbs”).
2. Basic operations with flags
Left shift <<: how to get a single-flag mask
When you see Go code like 1 << n, it is almost always about flags. If you look at it in binary, 1 is 0b0001. The << n shift simply “moves” this single one to the left: 1 << 3 → 0b1000, 1 << 5 → 0b100000.
That is exactly why 1 << n is so good for creating a “single-flag” mask. The idea is simple: take the number 1 (in binary this is ...0001) and move the single one n positions to the left.
Let’s do a small experiment and see what “one bit” looks like at different positions. This will help you stop treating << as magic and start seeing it as a normal way to “select bulb number n”.
package main
import "fmt"
func main() {
var mask uint = 1 << 3
fmt.Printf("mask=%b (%d)\n", mask, mask) // mask=1000 (8)
}
Here, 1 << 3 gave us 1000 (in decimal that is 8). This is the mask “only bit #3 is enabled”.
A practical habit: for flags, people often use uint, because it is “just bits”, without surprises related to sign.
Bitwise OR |: how to enable a flag in a set
When you store a set of flags in one number, the “enable a flag” operation is done via | (bitwise OR). Semantically, it is like saying: “turn on this bulb, but do not touch the others”.
Let’s imagine we have two flags: one at bit 0, the other at bit 2. First we enable the first one, then we add the second — and see what we get.
package main
import "fmt"
func main() {
var a uint = 1 << 0 // 0001
var b uint = 1 << 2 // 0100
var flags uint = a | b
fmt.Printf("%b\n", flags) // 101
}
In binary we got 101: bits 0 and 2 are enabled. The important point is that | does not “add numbers” in the usual sense; it merges the enabled bits.
Bitwise AND &: how to check whether a flag is enabled
A flag check usually looks like this: (flags & SomeFlag) != 0. This reads as “the intersection of the flag set with the mask is not empty”.
In other words, if the target bit was enabled, it will remain after &, and the result will not be zero.
Now we will do exactly what you will most often see in real Go code: “does the user have write permission?”.
package main
import "fmt"
func main() {
const (
read uint = 1 << 0 // 001
write uint = 1 << 1 // 010
)
var perms uint = read // read-only
fmt.Println(perms&write != 0) // false
fmt.Println(perms&read != 0) // true
}
Notice: flags are almost never checked with ==. The check perms == read means “I have exactly read enabled and nothing else”, not “I have read”.
Bitwise AND NOT &^: how to clear a flag
The &^ operator in Go is read as “AND NOT”, meaning “keep the bits except the ones specified in the mask”. This is the standard “turn off a flag” operation without affecting the rest.
If | is “turn on a bulb”, then &^ is “turn off a bulb” by mask.
package main
import "fmt"
func main() {
const (
read uint = 1 << 0 // 001
write uint = 1 << 1 // 010
exec uint = 1 << 2 // 100
)
var perms uint = read | write | exec // 111
perms = perms &^ write
fmt.Printf("%b\n", perms) // 101
}
After &^ write, the write bit disappears, and the others remain.
XOR ^ and unary ^x: toggling and inversion
The ^ symbol in Go has two meanings — and this is the moment where beginners usually frown:
- a ^ b — XOR (exclusive OR), bitwise.
- ^a — bitwise NOT (invert all bits).
XOR is useful when you want to “toggle” a bit: if it was 0 it becomes 1, if it was 1 it becomes 0.
First, XOR:
package main
import "fmt"
func main() {
var x uint = 0b1010
var y uint = 0b1100
fmt.Printf("%b\n", x^y) // 110
}
And now “toggle a flag”:
package main
import "fmt"
func main() {
const debug uint = 1 << 0
var flags uint = 0
flags = flags ^ debug // enabled debug
flags = flags ^ debug // disabled debug
fmt.Println(flags) // 0
}
Unary ^x is inversion of all bits. In the context of flags, this is used more rarely (and more carefully), because you invert all bits of the number, not only the “flags you know about”. That is why for “clearing specific flags” people usually use &^.
Right shift >>: how to extract a bit and simple decomposition
Right shift >> appears more often when you need to “read” a single bit or a small bit field. For example, you want to know the value of bit #3: you can shift the number right by 3 and take & 1.
>> moves the desired bit into position 0, and & 1 extracts only it.
package main
import "fmt"
func main() {
var flags uint = 0b10100 // bit 2 is enabled, bit 4 is enabled
bit2 := (flags >> 2) & 1
bit3 := (flags >> 3) & 1
fmt.Println(bit2, bit3) // 1 0
}
This is not the “only” way to check a flag (usually flags & mask != 0 is simpler), but it is useful to know, because you will see it in problems where multiple values are encoded inside one number.
3. Operator precedence and parentheses
Bitwise operators have precedence rules, and sometimes an expression without parentheses turns into a puzzle even for its author two days later. The good news: if you add parentheses “a bit more than necessary”, the compiler will not mind, and your coworkers will thank you.
Expressions are especially dangerous when they mix &, |, ^, and comparison with 0. Even if you know the precedence rules, your brain reads such code slowly.
So the practical rule is simple: if the expression is longer than one line in your head, add parentheses and create intermediate variables.
An example of a clean check:
package main
import "fmt"
func main() {
const (
read uint = 1 << 0
write uint = 1 << 1
)
perms := read | write
canWrite := (perms & write) != 0
fmt.Println(canWrite) // true
}
4. Debugging masks: %b, width, and leading zeros
When you debug flags, decimal output (13) is almost useless: you cannot see the “bulbs”. So it is important to keep masks readable already in code (via 0b... and _), and use binary output only as a quick check when you are unsure.
And if you do print the number, binary format (1101) is much more useful than decimal. But it has a drawback: leading zeros are usually not printed, and it is hard to understand “how many bits are we even looking at”.
For debugging, it is convenient to print a fixed width, for example 8 bits, 16 bits, 32 bits. Then you see the whole “switch panel”.
In fmt.Printf, this is done with formats like %08b (8 characters wide, pad with zeros).
package main
import "fmt"
func main() {
var x uint = 5
fmt.Printf("%b\n", x) // 101
fmt.Printf("%08b\n", x) // 00000101
}
5. Mini example: user permissions via flags
Let’s continue our learning “console mini-service”: read input, compute a result, print output. Today we will add a simple access-control model: read, write, execute — and learn to check whether an action is allowed.
It is important not only to “write working code”, but also to keep it readable. We will use type Perm uint so that numbers become a meaningful type, and define flags via 1 << iota, so that each flag is a power of two rather than “1, 2, 3”.
Declare the type and flags
package main
import "fmt"
func main() {
type Perm uint
const (
PermRead Perm = 1 << iota
PermWrite
PermExec
)
var p Perm = PermRead | PermExec
fmt.Printf("perms=%b\n", uint(p)) // perms=101
}
Note an important detail: we print uint(p), because %b is more convenient to apply to a plain numeric type. Perm is also a number, but beginners sometimes find it easier to think about it this way.
Read the operation code and decide whether it is allowed
Let’s make a very simple input protocol: the user enters the current permissions number (for example, 5) and an operation (1=read, 2=write, 3=exec). We check it and print "allowed" or "denied".
Our goal is for a line like p&PermWrite != 0 to stop looking scary.
package main
import "fmt"
func main() {
type Perm uint
const (
PermRead Perm = 1 << iota
PermWrite
PermExec
)
var raw uint
var op int
fmt.Scan(&raw, &op)
p := Perm(raw)
allowed := false
if op == 1 {
allowed = (p & PermRead) != 0
} else if op == 2 {
allowed = (p & PermWrite) != 0
} else if op == 3 {
allowed = (p & PermExec) != 0
}
if allowed {
fmt.Println("allowed")
} else {
fmt.Println("denied")
}
}
This code uses only what we already know: input via fmt.Scan, if/else, constants, types, and bitwise operators.
Admin mode: clear write permission
Now let’s add one more “mode”: if op == 99, we clear write and print the new mask value. This is an exercise on &^.
Many people “get” &^ for the first time in exactly this scenario: it does exactly “remove this flag”.
package main
import "fmt"
func main() {
type Perm uint
const (
PermRead Perm = 1 << iota
PermWrite
PermExec
)
var raw uint
var op int
fmt.Scan(&raw, &op)
p := Perm(raw)
if op == 99 {
p = p &^ PermWrite
fmt.Printf("%d %b\n", uint(p), uint(p)) // e.g.: 5 101
return
}
fmt.Println("ok")
}
Here we used return so as not to complicate the rest of the logic. This is fine: an early exit often makes code easier to read.
6. Operator cheat sheet
Keeping all operators in your head at once is hard, especially if you do not write this kind of code often yet. The table below is not for memorization, but for a quick “oh, that is what this symbol does” while reading code.
The key is to understand the typical patterns: enable (|), check (&), clear (&^), create a mask (1 << n).
| Operator | Name (meaning) | Typical meaning for flags | Mini metaphor |
|---|---|---|---|
|
left shift | get a one-bit mask | “put 1 at position n” |
|
OR | enable flags from b in a | “merge bulbs” |
|
AND | check whether a flag is present | “intersection of bulbs” |
|
AND NOT | clear flag(s) by mask | “turn off bulbs from the mask” |
|
XOR | toggle bits where they differ | “toggle (sometimes)” |
|
NOT (unary) | invert all bits | “flip everything” |
|
right shift | extract a bit/field down | “shift toward lower bits” |
7. Common mistakes when working with bitwise operators
Mistake #1: defining flags as 1, 2, 3 instead of powers of two.
This is the most common logical mistake. For a set of flags, each flag must occupy a separate bit: 1, 2, 4, 8... If you do 1, 2, 3, then flag 3 in binary is 11 and effectively enables two bits at once. As a result, checks and combinations start behaving strangely.
Mistake #2: checking a flag with == instead of &.
The expression perms == PermRead answers the question “I have only read enabled and nothing else”. But the question “do I have read?” should be phrased as (perms & PermRead) != 0. If you mix up these approaches, a user with read+write suddenly “does not have read” according to your check.
Mistake #3: confusing ^ as XOR with “exponentiation”.
Go does not have an exponent operator ^. The ^ symbol is bitwise XOR (or bitwise inversion in unary form). If you try to write 2 ^ 3 expecting 8, you will get the XOR result.
Mistake #4: using unary ^mask to clear a flag and being surprised by the result.
Technically, you can clear a flag like this: flags = flags & (^mask), but it is harder to read and easy to get wrong. In Go, there is a direct operator for this: &^, and it expresses the intent much more honestly: “clear bits by mask”.
Mistake #5: not printing the binary representation while debugging and trying to understand flags via decimal numbers.
The number 13 by itself does not tell you which flags are enabled. But 1101 does. If you debug flags and do not use %b (and sometimes also %08b), you are literally making your life harder by hand.
GO TO FULL VERSION