← Back to Core Python
Lesson 8 · Operators and Control Flow

Operators

Arithmetic, comparison, logical, identity, membership, and bitwise.

Beginner35 min

What you will be able to do

  • Name the seven operator families and say what each one is for
  • Use the augmented assignment operators, and know what they do to mutable objects
  • Explain the difference between == and is, and why is sometimes appears to work
  • Combine conditions with and, or, not - and use chained comparisons
  • Explain short-circuit evaluation and the None guard it makes possible
  • Recognise bitwise operators and the permission-flag pattern they appear in

The idea, in plain English

An operator tells Python to do something with one or two values. The values are the operands, the symbol is the operator, and the whole thing is an expression that evaluates to a result. You have been using them since lesson one.

Python has seven families. Arithmetic you met properly in lesson 4 and this lesson will not repeat it. What is new here is everything you need for decisions: assignment shortcuts, comparison, logic, identity, membership, and - briefly - bitwise.

The reason this lesson comes before if statements is that a condition is just an expression that evaluates to True or False. Once you can read `is_active and role in ("admin", "trainer")`, the if statement around it is the easy part.

Three things in here genuinely surprise people: `is` is not `==`, `and` does not return True or False, and Python stops evaluating an expression the moment it knows the answer. All three matter in real code.

Worked example: Deciding whether a learner can enrol, from four separate conditions.

== compares values, is compares identity

== asks "do these two things have the same value?". `is` asks "are these two names pointing at the same object?". They are different questions and they usually have different answers.

Two lists with identical contents are equal but are not identical - there are two objects. This is the reference model from lesson 2, showing up as an operator.

What makes this dangerous is that `is` sometimes appears to work. CPython reuses small integers and short strings, so `256 is 256` is True while `257 is 257` may be False, and it can differ between the REPL and a script. Code that passes your test and fails in production is worse than code that always fails.

Use `is` for exactly one thing: comparing against None, True, and False, which are singletons. Everything else uses ==.

Which one to reach for
x == ySame value. The default choice for comparing anything.
x is NoneThe correct None check. None is a singleton, so identity is exactly the question.
x is not NoneThe negation. Note the word order - not `not x is None`.
x is TrueAlmost never what you want. Write `if x:` instead, or `x == True` if you really mean the value.
a is b on listsTrue only when both names refer to one list. Two equal lists are == but not is.
x is 256A trap. It may be True by accident of interning, and Python 3.8+ warns about it.

Watch out: Python raises a SyntaxWarning for `is` against a literal - "is" with a literal. If you see it, you meant ==.

and and or return an operand, not a boolean

This surprises everyone once. `"Python" and "Java"` evaluates to "Java", not True. `"" or "Python"` evaluates to "Python", not True.

The rule: `a and b` returns a if a is falsy, otherwise b. `a or b` returns a if a is truthy, otherwise b. When both operands happen to be booleans you get a boolean back, which is why this goes unnoticed for so long.

It powers a common idiom: `name = supplied or "Anonymous"` gives a default when supplied is empty. It also carries a bug, because 0 and "" and [] are all falsy - `port = configured or 8080` quietly replaces a deliberately configured port of 0. When None is the only value you want to replace, write the check out in full.

What these expressions actually evaluate to
"a" and "b""b" - the first operand is truthy, so the second is the answer.
"" and "b""" - the first is falsy, so it is returned and the second is never evaluated.
"" or "b""b" - the first is falsy, so the second is the answer. This is the default-value idiom.
"a" or "b""a" - the first is truthy, so the second is never evaluated.
not "a"False - not always returns a real boolean, unlike and and or.
0 or 80808080 - and that is the bug, if 0 was a deliberate setting.

Short-circuiting, and the guard it enables

Python stops evaluating as soon as the answer is certain. In `a and b`, if a is falsy the result is already known, so b is never evaluated at all. In `a or b`, if a is truthy the same applies.

That is not an optimisation detail - it is something you write code to depend on. `if user is not None and user.is_active:` is safe precisely because the second half never runs when user is None. Reverse the two halves and it crashes.

The same shape guards against division by zero, empty collections, and expensive calls: `if items and items[0] == x`, or `if cache_hit or expensive_lookup()`.

Tip: Order your conditions cheapest-and-safest first. The condition that prevents the error has to come before the one that would cause it.

Bitwise, and where you actually meet it

Bitwise operators work on the individual binary digits of an integer. You will not need them often, but they show up in permission systems, protocol code, and anywhere a set of on/off flags is packed into one number.

The pattern is worth recognising even if you never write it: give each permission a power of two, combine them with |, and test one with &. READ=1, WRITE=2, DELETE=4 means a value of 3 is read plus write, and `permissions & WRITE` is non-zero exactly when write is granted.

The one result that looks wrong is ~5 giving -6. Python integers are signed and conceptually infinite, so inverting every bit follows ~x == -x - 1. It is consistent, just not intuitive.

On 5 (0101) and 3 (0011)
5 & 31 - AND keeps bits set in both. 0101 & 0011 = 0001.
5 | 37 - OR keeps bits set in either. This is how flags are combined.
5 ^ 36 - XOR keeps bits set in exactly one.
~5-6 - inverts every bit. Always equals -x - 1 for signed integers.
5 << 110 - shift left one place, which doubles a positive integer.
20 >> 25 - shift right two places, which is floor division by 4.

Watch out: & is not and. & has higher precedence than ==, so `a == 1 & b == 2` parses as `a == (1 & b) == 2`. It also never short-circuits. Use and for logic.

Syntax and examples

Augmented assignment
count = 10 count += 5 # count = count + 5 count -= 3 # 12 count *= 2 # 24 count //= 5 # 4 count **= 2 # 16 print(count) # 16 value = 100 value /= 4 print(value) # 25.0 <- /= always gives a float # On a list, += changes the list in place; + builds a new one a = [1, 2] b = a a += [3] # same object, so b sees it print(b) # [1, 2, 3] a = [1, 2] b = a a = a + [3] # new object, so b does not print(b) # [1, 2]
Comparison, including chains
print(34 == 34, 34 != 30) # True True print("apple" < "banana") # True - character by character print("Python" == "python") # False - case matters # Chained, which reads the way the maths does age = 25 print(18 <= age <= 60) # True print(0 <= 85 <= 100) # True # Equality across types is False, ordering is an error print(10 == "10") # False # print(10 < "20") # TypeError
== against is
a = [1, 2, 3] b = [1, 2, 3] c = a print(a == b) # True same contents print(a is b) # False two separate list objects print(a is c) # True one object, two names # The trap: interning makes 'is' look correct for small values x = 256; y = 256 print(x is y) # True - cached by CPython x = 257; y = 257 print(x is y) # often False - and that is the point # The only identity checks worth writing result = None print(result is None) # True print(result is not None) # False
Logic that returns operands
print("Python" and "Java") # Java - not True print("" or "Python") # Python - the default-value idiom print(not "Python") # False - not does return a boolean name = "" print(name or "Anonymous") # Anonymous # ...and the bug hiding in that idiom configured_port = 0 print(configured_port or 8080) # 8080 - overrode a deliberate 0 port = configured_port if configured_port is not None else 8080 print(port) # 0 - correct
Short-circuiting as a guard
user = None # Safe: the second half never runs when the first is False if user is not None and user.is_active: print("active") else: print("no user") # no user items = [] if items and items[0] > 10: # items[0] would raise on an empty list print("big") divisor = 0 if divisor != 0 and 100 / divisor > 1: # never divides by zero print("ok")
Membership
skills = ["Python", "React", "Node.js"] print("Python" in skills) # True print("Java" not in skills) # True print("Py" in "Python") # substring, for strings # On a dict, 'in' looks at the keys user = {"name": "Chandu", "role": "Developer"} print("name" in user) # True print("Chandu" in user) # False - that is a value print("Chandu" in user.values()) # True # The clean way to test several options role = "trainer" print(role in ("admin", "trainer")) # better than role == "admin" or role == "trainer"
Bitwise flags
READ, WRITE, DELETE = 1, 2, 4 # powers of two permissions = READ | WRITE # combine print(permissions) # 3 -> 0b011 print(bool(permissions & READ)) # True - granted print(bool(permissions & DELETE)) # False - not granted permissions |= DELETE # grant permissions &= ~WRITE # revoke print(f"{permissions:03b}") # 101 -> read and delete
Putting it together: can this learner enrol?
is_logged_in = True account_active = True age = 25 course_available = True role = "trainer" # Each line says one thing, so the failing one is obvious is_adult = 18 <= age can_enrol = is_logged_in and account_active and is_adult and course_available can_edit = is_logged_in and account_active and role in ("admin", "trainer") print(f"Can enrol: {can_enrol}") # True print(f"Can edit: {can_edit}") # True

Tip: A chained comparison evaluates the middle expression once: in `f(x) < g(y) < h(z)`, g(y) is called a single time. Writing it out with and would call it twice.

The seven families

Arithmetic

+ - * / // % ** - covered in lesson 4. Remember / always returns a float.

10 // 3   # 3
Assignment

= and the augmented forms += -= *= /= //= %= **=, which update in place.

count += 1
Comparison

== != > < >= <= - always return a bool, and can be chained.

18 <= age <= 60
Logical

and or not - combine conditions, short-circuit, and return an operand.

a and b
Identity

is, is not - same object, not same value. For None, True, False.

x is None
Membership

in, not in - is this inside that. Keys for a dict, characters for a string.

"Py" in name
Bitwise

& | ^ ~ << >> - operate on the bits of an integer. Flags and low-level work.

flags & READ

Precedence, highest first

You do not need to memorise this. You need to know that comparison binds tighter than not, which binds tighter than and, which binds tighter than or - and to add parentheses everywhere else.

( )

Parentheses. The only tool you need to override everything below.

**

Exponentiation. Right-associative, and tighter than unary minus.

+x -x ~x

Unary plus, minus, and bitwise NOT.

* / // %

Multiplication and the division family.

+ -

Addition and subtraction.

<< >>

Bit shifts - lower than arithmetic, higher than comparison.

& ^ |

Bitwise AND, XOR, OR - in that order, and all tighter than comparison.

in is < <= > >= != ==

Comparison, membership and identity, all at the same level.

not

Logical NOT - lower than comparison, so `not a == b` means `not (a == b)`.

and

Logical AND.

or

Logical OR - the loosest of all, which is why it usually needs no parentheses.

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

and returns an operand

“print("a" and "b", "" or "b", not "a")”

The interning trap

“x=256;y=256;print(x is y); x=257;y=257;print(x is y)”

+= on a shared list

“a=[1];b=a;a+=[2];print(b); a=[1];b=a;a=a+[2];print(b)”

& is not and

“print(True & False, 1 & 2, bool(1 and 2))”

What usually goes wrong

Writing = where you meant ==

Assignment inside an if is a SyntaxError in Python, which is a kindness - in C it silently assigns and the condition is almost always true.

✗ if age = 18:
✓ if age == 18:
Using is to compare values

It asks whether two names point at one object, not whether the values match. It sometimes appears to work because CPython caches small integers and short strings, which makes it worse - the bug survives testing.

✗ if username is "Chandu":
✓ if username == "Chandu":
Using == to check for None

It usually works, but None is a singleton so identity is the precise question, and a class can override __eq__ to make == lie. `is None` cannot be overridden.

✗ if result == None:
✓ if result is None:
Relying on `or` for defaults when 0 is valid

Every falsy value takes the fallback, not just None. A configured port of 0, a quantity of 0, or an empty string that was deliberately set all get silently replaced.

✗ port = configured or 8080
✓ port = 8080 if configured is None else configured
Using & or | for logic

They are bitwise operators. They do not short-circuit, and they bind tighter than == - so `a == 1 & b == 2` is parsed as `a == (1 & b) == 2`, which is not remotely what you wrote.

✗ if is_active & is_admin:
✓ if is_active and is_admin:
Repeating the variable instead of using in

A tuple membership test says the intent directly and does not repeat the name, so it cannot go wrong when you add a third option.

✗ if role == "admin" or role == "trainer":
✓ if role in ("admin", "trainer"):
Mixing and and or without parentheses

and binds tighter than or, so the grouping may not be what you meant. Even where it is, the parentheses tell the next reader you knew.

✗ if age >= 18 and role == "admin" or role == "trainer":
✓ if age >= 18 and role in ("admin", "trainer"):

Best practices

  • Use == for values and reserve is for None, True, and False.
  • Write `x is None`, never `x == None`.
  • Use chained comparisons for ranges: 0 <= score <= 100.
  • Use `in` with a tuple rather than a chain of or-ed equality tests.
  • Put the guarding condition first so short-circuiting protects the one after it.
  • Give complex conditions a name - can_enrol = ... - so the if statement reads as a sentence.
  • Use and and or for logic, & and | only when you genuinely mean bits.
  • Add parentheses whenever and and or appear in the same expression.

Practice

Write these yourself before opening anything. Getting them wrong first is most of how this sticks.

1.

Given price = 5000 and discount = 0.10, work out the discount amount and the final price.

Show solution
price = 5000 discount = 0.10 discount_amount = price * discount final_price = price - discount_amount print(f"Discount: {discount_amount:,.2f}") # 500.00 print(f"Final: {final_price:,.2f}") # 4,500.00
2.

Check whether age = 25 falls between 18 and 60 inclusive, using a chained comparison.

Show solution
age = 25 print(18 <= age <= 60) # True
3.

Given role = "trainer", allow access if the role is either admin or trainer - without repeating the variable.

Show hint

Membership against a tuple.

Show solution
role = "trainer" print(role in ("admin", "trainer")) # True
4.

Given result = None, check whether it is None in the idiomatic way.

Show solution
result = None if result is None: print("No result") # No result
5.

Create two separate lists with identical contents. Show that they are equal but not identical, then make a third name that is identical to the first.

Show solution
a = [1, 2, 3] b = [1, 2, 3] c = a print(a == b) # True - same contents print(a is b) # False - two objects print(a is c) # True - one object, two names
6.

Given user = None, write a condition that checks user.is_active without raising an AttributeError.

Show hint

Short-circuiting does the work - the order of the two halves is the whole answer.

Show solution
user = None if user is not None and user.is_active: print("active") else: print("no active user") # no active user
7.

Using READ = 1, WRITE = 2, DELETE = 4, grant read and write, then check whether delete is granted.

Show solution
READ, WRITE, DELETE = 1, 2, 4 permissions = READ | WRITE print(permissions) # 3 print(bool(permissions & READ)) # True print(bool(permissions & DELETE)) # False
Coding challenge

Access control for the platform

Work out what a user is allowed to do from four separate facts about them. Then run every combination below and check each answer is what you intended.

It should
  • Decide can_view from: logged in, account active, and the course published
  • Decide can_edit from: logged in, account active, and a role of admin or trainer
  • Use `in` with a tuple for the role test rather than or-ed comparisons
  • Name each condition so the final expression reads as a sentence
  • Run the five cases below and print a table of the results
Start here
cases = [ ("admin", True, True, True), ("trainer", True, True, True), ("student", True, True, True), ("admin", True, False, True), # inactive account ("trainer", False, True, True), # logged out ] for role, logged_in, active, published in cases: ...
Show one solution
One solution
EDITOR_ROLES = ("admin", "trainer") cases = [ ("admin", True, True, True), ("trainer", True, True, True), ("student", True, True, True), ("admin", True, False, True), # inactive account ("trainer", False, True, True), # logged out ] print(f"{'role':<9}{'in':<6}{'active':<8}{'pub':<6}{'view':<7}{'edit'}") print("-" * 42) for role, logged_in, active, published in cases: # One named condition per rule - the failing one is then obvious. signed_in_and_ok = logged_in and active can_view = signed_in_and_ok and published can_edit = signed_in_and_ok and role in EDITOR_ROLES print(f"{role:<9}{logged_in!s:<6}{active!s:<8}{published!s:<6}" f"{can_view!s:<7}{can_edit!s}") # role in active pub view edit # ------------------------------------------ # admin True True True True True # trainer True True True True True # student True True True True False # admin True False True False False # trainer False True True False False

Key points

  • An operator acts on operands and produces a value; a condition is just an expression that evaluates to a bool.
  • Augmented assignment (+=, -=, ...) updates a name in place; on a list, += mutates while + builds a new list.
  • == compares values, is compares object identity. They answer different questions.
  • Use is only for None, True, and False. Small-integer caching makes it appear to work elsewhere.
  • Comparisons can be chained: 18 <= age <= 60, with the middle evaluated once.
  • and and or return one of their operands, not necessarily a boolean. not always returns a boolean.
  • `x or default` falls back on any falsy value, not just None - watch out for 0 and "".
  • Short-circuiting means the second operand may never be evaluated, which is what makes None guards safe.
  • `in` tests membership; on a dict it looks at the keys.
  • Bitwise operators work on bits and are the basis of permission flags. & is not and.
  • Precedence: comparison, then not, then and, then or. Parenthesise anything else.

Quick check before you move on

What does print(10 > 5 and 3 < 2) give, and what about `or` instead of `and`?
False with and, because the second condition fails. True with or, because the first succeeds - and with or the second is never even evaluated.
For a = [1, 2] and b = [1, 2], what do a == b and a is b give?
True and False. The contents are equal, but they are two separate list objects.
What does "" or "Python" evaluate to?
"Python" - the string itself, not True. or returns the first truthy operand, which is what makes it usable as a default.
Why does `if user is not None and user.is_active:` not crash when user is None?
Short-circuiting. The first half is False, so the result is already known and the second half is never evaluated.

Interview questions

Why does `256 is 256` return True but `257 is 257` may not?

CPython pre-allocates and caches integer objects from -5 to 256, so every reference to 256 is the same object. 257 is created fresh each time - though the peephole optimiser may still fold two literals in the same code block, which is why the answer differs between the REPL and a script. That inconsistency is exactly the argument for never using `is` on values.

What do `and` and `or` actually return?

One of their operands. `a and b` returns a when a is falsy, otherwise b; `a or b` returns a when a is truthy, otherwise b. The truthiness of the result matches the boolean logic, which is why it goes unnoticed, and it enables `x = value or default`. `not` is the exception - it always returns a real bool.

How does `a < b < c` differ from `(a < b) < c`?

Completely. The chained form means `a < b and b < c`, with b evaluated once. The parenthesised form compares a boolean against c - since True is 1, `1 < 2 < 3` is True but `(1 < 2) < 3` is `True < 3`, which is also True for a different and accidental reason.

Why is short-circuiting part of the language rather than an optimisation?

Because programs depend on it for correctness, not speed. `user is not None and user.is_active` is only safe if the second operand is genuinely skipped. A language that evaluated both would make that idiom crash, so the behaviour has to be guaranteed rather than best-effort.

When would you use bitwise operators in application code?

Packing a set of boolean flags into one integer - file permissions, feature toggles, protocol headers - where each flag is a power of two, | grants and & tests. Also for hashing, fast multiply or divide by powers of two, and masking in binary formats. In ordinary business logic, a set or a dataclass of booleans is clearer.

What is the difference between `x += [1]` and `x = x + [1]` for a list?

+= calls __iadd__ and extends the list in place, so every other name pointing at that list sees the change. x + [1] builds a new list and rebinds x, leaving the original untouched. For immutable types like int and str there is no difference, because there is no in-place option.

Quiz

  1. 1.

    What is the difference between = and ==?

  2. 2.

    What is the difference between == and is?

  3. 3.

    Why should you write `value is None` rather than `value == None`?

  4. 4.

    What is short-circuit evaluation?

  5. 5.

    What does `"" or "Python"` return, and why does it matter?

  6. 6.

    Why should you not use & in place of and?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...