Comparison and logical operators are how a program decides, and Python's versions have two behaviours that differ from most other languages: and and or return one of their operands rather than a boolean, and they short-circuit, meaning the right-hand side is never evaluated when the answer is already known. Both are useful once you understand them, and both cause baffling bugs when you do not. This lesson builds the mental model from the ground up.
What comparison operators does Python have?
| Operator | Meaning | Example |
|---|---|---|
== != | equal, not equal | 3 == 3.0 is True |
< <= > >= | ordering | 2 <= 2 <= 4 is True |
is is not | same object in memory | value is None |
in not in | membership | 'a' in 'cat' |
and or not | combine conditions | a and b |
a, b = 10, 20
print('a == b ->', a == b)
print('a != b ->', a != b)
print('a < b ->', a < b)
print('a <= b ->', a <= b)
print('a > b ->', a > b)
print('a >= b ->', a >= b)
print('"b" in "abc" ->', 'b' in 'abc')
print('5 in [1, 2] ->', 5 in [1, 2])
print('"k" in {"k": 1} ->', 'k' in {'k': 1})
print('5 not in [1, 2] ->', 5 not in [1, 2])
Membership works on strings, lists, tuples, sets and dictionaries. For a dictionary it checks the keys, not the values, which is a common surprise: 'k' in {'k': 1} is True because the key exists.
What do and, or and not actually return?
Here is the behaviour that surprises people. and and or do not convert their operands to booleans and return a boolean. They return one of the operands, chosen by the truthiness of the first one.
a and breturnsaifais falsy, otherwise it returnsb.a or breturnsaifais truthy, otherwise it returnsb.not aalways returns a real boolean, the inverse of the truthiness ofa.
def log_call(label, result):
print(f' evaluated {label}')
return result
print('and short-circuits when the left side is falsy:')
print(' result ->', False and log_call('right side', 'never reached'))
print('or short-circuits when the left side is truthy:')
print(' result ->', True or log_call('right side', 'never reached'))
print('and returns one of its operands:')
print(' 5 and 7 ->', 5 and 7)
print(' 0 and 7 ->', 0 and 7)
print('or returns one of its operands:')
print(' 0 or 7 ->', 0 or 7)
print(' 5 or 7 ->', 5 or 7)
print('not always returns a boolean:')
print(' not 0 ->', not 0)
print(' not 5 ->', not 5)
Short-circuiting is not an optimisation, it is a guarantee about side effects. If the left operand already decides the answer, the right operand is never evaluated, so a function call on the right never runs. That is how you write a safe guard such as if user is not None and user.name == 'ada': when the user is missing, the attribute access never happens and no exception is raised.
The operand-returning behaviour is useful when you want a default value: name = user_name or 'anonymous' yields the user name when it is a non-empty string and the fallback otherwise. It is also a trap, because x = a or b assigns a whenever a is truthy, even when a is 0 and you meant the number zero.
How do chained comparisons work?
Python allows comparisons to be chained, and the chain is evaluated as one unit rather than as a series of separate tests. 0 <= score <= 100 means "score is at least zero and score is at most 100", and it stops checking as soon as one part fails.
score = 75
print('0 <= score <= 100 ->', 0 <= score <= 100)
print('10 < 5 < 20 ->', 10 < 5 < 20)
a, b, c, d = 1, 1, 2, 2
print('a == b and c == d or a == c ->', a == b and c == d or a == c)
print('with explicit parentheses ->', (a == b and c == d) or (a == c))
names = ['ada', 'grace', 'alan']
print('all in list:', all(name in names for name in ('ada', 'alan')))
The second group shows why parentheses matter. Python gives comparison operators a higher precedence than and and or, so a == b and c == d or a == c is parsed as (a == b and c == d) or (a == c). That happens to be what most people intend, but the expression reads ambiguously and the moment you add an or in the middle of a conjunction, the meaning shifts. Parentheses cost nothing and remove the question entirely.
When should you use is instead of ==?
Equality compares the values two objects hold. Identity asks whether two names point at the same object in memory. For most values those questions have the same answer, which is why == is the right default. There are two situations where identity is the better question.
first = ['a']
second = ['a']
print('equal lists ->', first == second)
print('same object? ->', first is second)
value = None
print('value is None ->', value is None)
left = int('1000')
right = int('1000')
print('equal ints ->', left == right)
print('same object? ->', left is right)
Use is None and is not None whenever you are checking for a missing value. None is a singleton, so identity is exactly the right question, and == invites a class to redefine equality in a way that changes your answer. The same goes for the sentinel objects that libraries use to mean "no value supplied".
Never use is to compare numbers. Small integers are cached and shared, larger ones are not, and identical literals in the same file are often folded into a single object, so identity for numbers depends on details you should never rely on. Compare them with ==.
How do all() and any() differ from and and or?
all() returns True when every element of an iterable is truthy, and any() returns True when at least one element is truthy. Both accept a generator expression, which means they can check a stream of values without building a list first, and both short-circuit.
numbers = [2, 4, 6]
print('all even ->', all(number % 2 == 0 for number in numbers))
print('any odd ->', any(number % 2 == 1 for number in numbers))
print('all of empty ->', all([]))
print('any of empty ->', any([]))
def is_positive(number):
print(f' checking {number}')
return number > 0
print('any stops at the first match:')
print(' result ->', any(is_positive(number) for number in [-1, -2, 5, 6]))
Short-circuiting matters most with expensive checks. In the example, any() stops after the third value because that one is positive, so the fourth is never tested. The same trick works for validation: all() over a generator stops at the first problem, so you can report the first invalid field rather than collecting every failure.
There is a performance reason to prefer the generator form as well. all([x > 0 for x in values]) builds a complete list before all() looks at it, while all(x > 0 for x in values) evaluates each item as it goes and discards it immediately. For a long collection, or a check that is expensive to compute, the generator keeps memory flat and stops early.
Two edge cases to remember. all([]) is True and any([]) is False, because there is nothing that fails and nothing that succeeds. If your iterable can be empty and the answer matters, handle the empty case explicitly.
Your turn: write a password policy validator
- Write
is_strong(password)that returnsTrueonly when the password is at least 12 characters, contains an uppercase letter, a lowercase letter and a digit. Use chained comparisons where they fit andall()with a generator for the character classes. - Test it against a list of passwords, including one that is long enough but missing a digit, and print each result.
- Now write the same checks with explicit
andandorinstead, and confirm the two versions agree on every input. Then explain in a comment why theall()version is easier to extend. - Add a rule that rejects passwords containing a space, using the
inoperator, and verify that a password with a space is rejected even when every other rule passes. - Finish by writing
describe_failures(password)that returns a list of the rules the password breaks, using a list comprehension over a list of(rule_name, passed)pairs. This gives the user a useful message instead of a single false.
Operator cheat sheet
andreturns its first falsy operand or its last one;orreturns its first truthy operand or its last one.- Short-circuiting guarantees the right-hand side is skipped when the answer is already known, which is what makes guard conditions safe.
- Chained comparisons such as
0 <= x <= 100are one expression, not two, and they stop at the first failure. - Comparisons bind tighter than
andandor, so add parentheses when a condition mixes both. - Use
is Nonefor missing values,==for everything else, and neveristo compare numbers. all()andany()accept generators, short-circuit, and treat an empty iterable asTrueandFalserespectively.