DEV Community

Cover image for How I Finally Understood Bitwise Operators in Python
Haleem Akintayo
Haleem Akintayo

Posted on

How I Finally Understood Bitwise Operators in Python

A visual mental model for Python's <<, >>, &, |, ^, and ~ operators using numbered boxes and switch panels.

For a long time, bitwise operators were one of those topics I kept meaning to understand.

I would come across symbols like <<, >>, and &, wonder what they meant, and then move on. I knew they had something to do with binary numbers, but I had never taken the time to work through them properly.

Recently, someone asked me about them, and we started exploring them together.

At first, the formulas for bitwise operators seemed different from the symbols themselves. Why does shifting left multiply a number by a power of two? Why does shifting right use floor division? And how do operators such as &, |, and ^ decide which bits to keep?

The clearest way I found to answer those questions was to start with the basic idea behind binary numbers: a number is represented by bits, and each bit belongs to a position with a particular value. Once that foundation is clear, the shift and comparison operators become much easier to follow.

To explain it, let us begin with something simpler than Python code.

Imagine a row of numbered boxes

Imagine four boxes with these labels:

Box value 8 4 2 1
Selected? No Yes No Yes

We selected the boxes worth 4 and 1.

Add their values:

4 + 1 = 5
Enter fullscreen mode Exit fullscreen mode

Now replace "Yes" with 1 and "No" with 0:

Box value 8 4 2 1
Bit 0 1 0 1

That gives us 0101.

This is 5 written in binary.

A bit is simply a binary digit: 0 or 1. In this example, 1 means "include this position's value," while 0 means "leave it out."

Try another number:

Box value 8 4 2 1
Bit 1 0 1 0

The selected values are 8 and 2:

8 + 2 = 10
Enter fullscreen mode Exit fullscreen mode

So 1010 is 10 in binary.

The position matters. A 1 in the box worth 8 contributes more than a 1 in the box worth 1.

Python can show us the binary form of a number:

print(bin(5))   # 0b101
print(bin(10))  # 0b1010
Enter fullscreen mode Exit fullscreen mode

The prefix 0b tells us that the digits after it represent a binary number.

You may notice that Python writes 101 instead of 0101. The leading zero does not change the value, just as 005 and 5 describe the same amount.

With that picture in mind, the operators become easier to follow.

Left shift moves the bits into more valuable boxes

The left shift operator is <<.

The symbol has two parts:

  • The number on the left is the value whose bits we want to move.
  • The number on the right tells Python how many positions to move them.

For example:

print(5 << 1)  # 10
Enter fullscreen mode Exit fullscreen mode

Read this as: Take 5 and shift its bits one position to the left.

The number 5 is 0101 in our four-box example. Moving every bit one position to the left means that each 1 moves into the box immediately to its left, which has twice the value:

Box value 16 8 4 2 1
Before shifting 0 0 1 0 1
After one left shift 0 1 0 1 0

Before shifting, the selected positions were worth 4 and 1.

After shifting, those same 1 bits occupy positions worth 8 and 2.

Before: 4 + 1 = 5
After:  8 + 2 = 10
Enter fullscreen mode Exit fullscreen mode

A zero fills the empty position on the right.

Each bit has moved into a position worth twice as much. That is why one left shift doubles the number.

Shift again:

print(5 << 2)  # 20
Enter fullscreen mode Exit fullscreen mode

Here, 2 means "move the bits two positions to the left," not "multiply by 2 in one step."

Expression Binary result Decimal value
Starting number 101 5
5 << 1 1010 10
5 << 2 10100 20

The second number is the number of positions to shift. It is not the number to multiply by.

So 5 << 2 does not mean 5 × 2. It means "double 5 twice":

5 → 10 → 20
Enter fullscreen mode Exit fullscreen mode

This gives us the formula:

x << n == x * (2 ** n)
Enter fullscreen mode Exit fullscreen mode

Here, ** means "raised to the power of."

For our example:

5 << 2
5 * (2 ** 2)
5 * 4
Enter fullscreen mode Exit fullscreen mode

All three expressions give 20.

For Python integers, this rule works with nonnegative integer shift counts. Python also allows the number to grow beyond the boxes we draw; those boxes do not impose a fixed size.

Right shift moves the bits into less valuable boxes

The right shift operator is >>.

It works in the opposite direction from <<:

  • The number on the left is the value whose bits we want to move.
  • The number on the right tells Python how many positions to move them to the right.

For example:

print(5 >> 1)  # 2
Enter fullscreen mode Exit fullscreen mode

Read this as: Take 5 and shift its bits one position to the right.

The number 5 is 0101. Moving every bit one position to the right means that each 1 moves into the box immediately to its right, which has half the value:

Box value 8 4 2 1
Before shifting 0 1 0 1
After one right shift 0 0 1 0

The 1 in the position worth 4 moves into the position worth 2.

The 1 at the far right falls off. There is no position for it to occupy in this integer result.

We are left with 0010, which is 2.

That dropped bit explains something important:

print(5 / 2)   # 2.5
print(5 >> 1)  # 2
Enter fullscreen mode Exit fullscreen mode

Right shift does not return the fractional half.

Its formula uses floor division:

x >> n == x // (2 ** n)
Enter fullscreen mode Exit fullscreen mode

For example:

print(5 >> 1)   # 2
print(5 // 2)   # 2
print(5 >> 2)   # 1
print(5 // 4)   # 1
Enter fullscreen mode Exit fullscreen mode

Floor division rounds the mathematical result down to an integer.

For positive numbers, it can look like simply removing the decimal part. Negative numbers show why "round down" is the more accurate explanation:

print(-5 >> 1)  # -3
print(-5 // 2)  # -3
Enter fullscreen mode Exit fullscreen mode

Half of -5 is -2.5. Rounding down gives -3.

This is also why Python's right shift is described as a signed right shift. The positive-number picture of filling empty positions with zeros needs extra care when negative numbers are involved.

Now imagine each bit as a switch

Shifts move bits between positions.

The next three operators compare the bits in matching positions.

Imagine two panels of switches. Each switch can be:

  • 0: off
  • 1: on

We will use the numbers 5 and 3:

Position value 8 4 2 1
First panel — 5 0 1 0 1
Second panel — 3 0 0 1 1

Each operator follows a different rule for deciding which switches should be on in the result.

AND asks whether both switches are on

The bitwise AND operator is &.

Its rule is: Keep a position on only when both input switches are on.

Think of a light controlled by two switches, where both must be on before the light comes on.

For 5 and 3:

Position value 8 4 2 1
5 0 1 0 1
3 0 0 1 1
AND result 0 0 0 1

Only the position worth 1 is on in both numbers.

print(5 & 3)  # 1
Enter fullscreen mode Exit fullscreen mode

The question to remember is: Do both numbers have a 1 here?

OR asks whether at least one switch is on

The bitwise OR operator is |.

Its rule is: Keep a position on when either input switch is on.

Imagine a light that comes on if either of two switches is on. It stays on when both are on too.

Position value 8 4 2 1
5 0 1 0 1
3 0 0 1 1
OR result 0 1 1 1

The selected values are 4, 2, and 1:

4 + 2 + 1 = 7
Enter fullscreen mode Exit fullscreen mode

Therefore:

print(5 | 3)  # 7
Enter fullscreen mode Exit fullscreen mode

The question is: Does at least one number have a 1 here?

XOR asks whether the switches are different

The bitwise XOR operator is ^.

Its rule is: Keep a position on when exactly one input switch is on.

If both are on, the result is off. If both are off, the result is also off.

Position value 8 4 2 1
5 0 1 0 1
3 0 0 1 1
XOR result 0 1 1 0

The position worth 1 disappears because both numbers have a 1 there.

The positions worth 4 and 2 remain because their input bits differ.

print(5 ^ 3)  # 6
Enter fullscreen mode Exit fullscreen mode

The question is: Are the two bits different here?

Together, the three rules look like this:

| First bit | Second bit | AND (&) | OR (\|) | XOR (^) |
| :---: | :---: | :---: | :---: | :---: |
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |

NOT flips the switches, with one Python detail

The bitwise NOT operator is ~.

It changes 0 bits to 1 and 1 bits to 0.

Looking at four switches, we might expect this:

Position value 8 4 2 1
Original bits 0 1 0 1
Flipped bits 1 0 1 0

The flipped four-bit pattern is 1010, or 10.

But Python gives us this:

print(~5)  # -6
Enter fullscreen mode Exit fullscreen mode

Why?

Python has not agreed to stop flipping after four bits.

The four boxes were our drawing. An ordinary Python integer is not restricted to those four positions.

Python defines integer bitwise operations using a model called two's complement with infinitely many sign bits. This is a rule for how the operations behave, not a claim that Python physically stores infinitely many bits.

For now, the useful identity is:

~x == -(x + 1)
Enter fullscreen mode Exit fullscreen mode

So:

~5 = -(5 + 1) = -6
Enter fullscreen mode Exit fullscreen mode

Try a few more:

print(~0)  # -1
print(~1)  # -2
print(~5)  # -6
Enter fullscreen mode Exit fullscreen mode

If we specifically want the result from flipping only our four switches, we can keep just the lowest four bits:

print((~5) & 0b1111)  # 10
Enter fullscreen mode Exit fullscreen mode

0b1111 acts as a mask. Think of it as a window with four open spaces: AND lets those four positions through and clears the higher ones.

That gives us the four-bit result we originally pictured.

Where would I actually use this?

Imagine an application with three settings:

  • Allow reading.
  • Allow editing.
  • Allow sharing.

We could give each setting its own bit:

READ = 1   # 001 (1 << 0)
EDIT = 2   # 010 (1 << 1)
SHARE = 4  # 100 (1 << 2)
Enter fullscreen mode Exit fullscreen mode

To allow reading and sharing, combine the flags with OR:

settings = READ | SHARE
print(settings)  # 5
Enter fullscreen mode Exit fullscreen mode

That 5 represents 101: sharing is on, editing is off, and reading is on.

To check whether editing is enabled, use AND:

print(bool(settings & EDIT))  # False
Enter fullscreen mode Exit fullscreen mode

To check reading:

print(bool(settings & READ))  # True
Enter fullscreen mode Exit fullscreen mode

AND checks whether the relevant bit is present. bool() turns the result into True or False.

The switch analogy now describes something an application might actually store.

Two easy mistakes to avoid

First, & and and are different operators:

print(5 & 3)    # 1
print(5 and 3)  # 3
Enter fullscreen mode Exit fullscreen mode

& compares the bits.

and checks truthiness and short-circuits. Since 5 is truthy, it evaluates and returns the second operand, 3.

Second, ^ means XOR in Python. Powers use **:

print(2 ^ 3)   # 1
print(2 ** 3)  # 8
Enter fullscreen mode Exit fullscreen mode

That distinction matters when using the shift formulas.

What I would tell someone learning this now

Start with the numbered boxes.

Understand why 0101 represents 5. Then move the bits and watch how their position values change. After that, compare two rows of switches using AND, OR, and XOR.

The formulas become easier to remember when you can explain them:

  • A left shift moves bits into positions worth twice as much.
  • A right shift moves bits into positions worth half as much, with floor division determining the integer result.

Working through this with someone finally got me to spend time on a topic I had kept postponing.

If you have been doing the same, try this before running any code:

What will 6 & 3 produce?

Click to reveal the answer

6 in binary: 110
3 in binary: 011

AND result: 010

Answer: 2

Write both numbers in binary. Line up their bits. Keep only the positions where both have a 1.

Then let Python check your answer.


Let's talk in the comments 💬

I wrote this guide because for a long time, I skipped right past symbols like <<, &, and ~ until someone asked me about them and we worked through the mental model together. I hope picturing numbered boxes and switch panels saves you the time I spent postponing it!

  • What did you get for 6 & 3?
  • Do you have any questions about how Python handles shifts, masks, or ~?
  • Are there other "postponed" Python topics you'd like to explore together next?

Drop your questions or thoughts in the comments below , I read and reply to every one!

References

Top comments (0)