
The Walrus Operator (:=) in Python: When It Helps and When It Hurts
The walrus operator, :=, landed in Python 3.8 after one of the most heated debates in the language's history. Its official name is the assignment expression, and it does one thing: assigns a value to a name and returns that value, so you can use it in the middle of an expression. The nickname comes from the way := looks like a walrus lying on its side.
Used in the right places, it removes duplicated function calls and awkward "assign, then check" boilerplate. Used everywhere, it turns straightforward code into puzzles. The trick is knowing which situations are which.
This post covers what := does and how it differs from =, the patterns where it clearly improves code, the precedence and scoping rules that cause bugs, the places where it isn't allowed, and some honest guidance on when to leave it out.
Assignment Statement vs Assignment Expression
A normal assignment with = is a statement. It doesn't produce a value, so you can't put it inside an if condition, a function call, or a comprehension. That's deliberate: it prevents the classic C bug of writing if (x = 0) when you meant if (x == 0).
:= is an expression. It binds the name and evaluates to the assigned value:
print(n := 5)
print(n)
5
5
The print received 5, and n stays bound afterward like any other variable.
To keep := from replacing = in ordinary code, Python doesn't allow it unparenthesized as a top-level statement:
y := 10
SyntaxError: invalid syntax
(y := 10) is technically legal, but there's no reason to write it. Use = for plain assignments. The walrus only makes sense when you need the value right where you assign it.
The full rationale is in PEP 572, which is worth skimming for its examples.
Where It Helps
There are a handful of patterns where := is clearly an improvement. They share a shape: you compute a value, test it, and then use it.
Regex Matches
Without the walrus, checking a regex match takes two lines:
import re
text = "order 1234 shipped"
m = re.search(r"\d+", text)
if m is not None:
print(m.group())
With it, the assignment moves into the condition:
if (m := re.search(r"\d+", text)) is not None:
print(m.group())
1234
The payoff grows with several patterns tried in sequence. Without assignment expressions you end up with an ever-deeper staircase of else: blocks, because each m = ... has to be its own statement:
import re
def classify(line: str) -> str:
if m := re.fullmatch(r"(\w+)=(\d+)", line):
return f"int setting {m[1]}={int(m[2])}"
elif m := re.fullmatch(r"(\w+)=(.*)", line):
return f"str setting {m[1]}={m[2]!r}"
elif m := re.fullmatch(r"#\s*(.*)", line):
return f"comment: {m[1]}"
return "unknown"
for line in ["port=8080", "host=example.com", "# hi", "???"]:
print(classify(line))
int setting port=8080
str setting host='example.com'
comment: hi
unknown
Each branch tries a pattern and uses the result immediately, and the chain stays flat.
Read-Until-Done Loops
Loops that read data until there's nothing left traditionally need either a duplicated read or a while True with a break:
# Without walrus
chunk = f.read(4096)
while chunk:
process(chunk)
chunk = f.read(4096)
The walrus collapses the read and the test into the loop header:
import io
buf = io.BytesIO(b"abcdefghij")
while chunk := buf.read(4):
print(chunk)
b'abcd'
b'efgh'
b'ij'
buf.read(4) returns b"" at the end of the stream, which is falsy, so the loop stops. The read call appears exactly once. The same pattern works for readline(), socket reads, queue polling, and paginated APIs that return an empty page at the end:
stream = io.StringIO("line one\nline two\n")
while line := stream.readline():
print(line.rstrip())
line one
line two
(For text files you'd normally just write for line in f:, but the walrus form is useful for any API that doesn't provide an iterator.)
Avoiding Repeated Work in Comprehensions
A common comprehension pattern is "transform each item and keep the non-empty results". Without the walrus you either compute the transformation twice or fall back to a loop:
def normalize(s: str) -> str:
return s.strip().lower()
raw = [" A ", " ", "B "]
# Calls normalize() twice per kept item
print([normalize(s) for s in raw if normalize(s)])
With :=, the filter computes the value once and the expression reuses it:
print([clean for s in raw if (clean := normalize(s))])
['a', 'b']
The filter runs first, binding clean, and the expression at the front of the comprehension reads it. This is worth it when the function is expensive (a network call, a parse, a database lookup). For a cheap str.strip(), the duplicated call is often more readable. You can find more comprehension patterns in List, Dict, and Set Comprehensions in Python.
Computing a Value Used in Both the Test and the Message
data = [3, 8, 1, 9]
if (count := len(data)) > 3:
print(f"too many items: {count}")
too many items: 4
This one is borderline. len() is cheap, and writing count = len(data) on its own line is just as clear. It becomes more convincing when the value is expensive to compute and only needed when the condition is true.
Precedence: Always Parenthesize
The walrus has the lowest precedence of any operator except the comma. That means it grabs everything to its right, which isn't always what you intend:
def get() -> int:
return 7
if x := get() > 5:
print(x)
if (x := get()) > 5:
print(x)
True
7
In the first if, Python parses x := (get() > 5), so x becomes True, not 7. The second version parenthesizes the assignment and gets the number. Any time the walrus is part of a larger expression (comparison, arithmetic, is not None), wrap it in parentheses. The only common case where you can skip them is when the walrus is the entire condition, as in while chunk := buf.read(4):.
Truthiness Traps
Using the walrus as a bare condition tests the value's truthiness, and 0, "", and empty containers are falsy:
def read_count() -> int:
return 0
if n := read_count():
print("got a count")
else:
print("n is", n)
n is 0
If zero or an empty string is a valid result, compare explicitly: if (n := read_count()) is not None:. That's why the regex example above used is not None in its first form; match objects are always truthy, so if m := ... is also fine there, but being explicit doesn't hurt. Understanding Python's Truthiness covers the rules in detail.
Scope: Comprehension Variables Leak (On Purpose)
Normal comprehension loop variables are local to the comprehension. Walrus targets are different: they bind in the enclosing scope. That's intentional, so you can capture a value found inside a comprehension or generator:
values = [1, 5, 12, 3]
print(any((hit := v) > 10 for v in values), hit)
True 12
any() stops at the first match, and hit is left holding the value that triggered it. This "witness" pattern is one of the motivating examples in PEP 572. Similarly, after the earlier normalize example, clean is still bound to the last kept value.
That leaking also enables running totals:
total = 0
print([total := total + v for v in values])
[1, 6, 18, 21]
It works, but itertools.accumulate(values) says the same thing without side effects. Leaking state from a comprehension is a feature to use sparingly. For more on how Python decides where a name lives, see Python Scope Explained.
Where := Isn't Allowed
Python blocks the walrus in places where it would be confusing or ambiguous:
[i := 0 for i in range(3)]
SyntaxError: assignment expression cannot rebind comprehension iteration variable 'i'
class C:
[z := i for i in range(3)]
SyntaxError: assignment expression within a comprehension cannot be used in a class body
def f(a := 1):
pass
SyntaxError: invalid syntax
Other restrictions:
- No unparenthesized top-level use, as shown earlier (
y := 10). - Not as a keyword argument value without parentheses.
dict(a=b := 2)is a syntax error;dict(a=(b := 2))works. - Only simple names as targets.
obj.attr := 1anditems[0] := 1are not allowed. You can't use the walrus to unpack, either:(a, b) := pairis invalid.
When It Hurts
PEP 572 itself lists a guideline: use assignment expressions only when they make code clearer. Some signs you've gone too far:
- The assignment is buried. If a reader has to scan into the middle of a long expression to discover that a variable was defined there, they'll miss it. Assignments are easiest to see at the start of a line.
- Multiple walruses in one expression.
if (a := f()) and (b := g(a)) and (c := h(b)):packs a whole function's worth of logic into a condition. Write separate statements or extract a helper. - It saves one line and nothing else.
if (count := len(data)) > 3:instead of two short lines doesn't buy much. The walrus earns its place when it removes duplication or flattens nesting. - Side effects inside comprehensions. Running totals and state mutation inside comprehensions make the result depend on evaluation order in ways readers won't expect.
- Your project supports Python 3.7 or older. Unlikely in 2026, but the syntax won't parse at all on old interpreters.
A good test: read the line out loud. "While there's a chunk from reading the buffer, process it" sounds natural. "If x, which is the result of calling get that's greater than five..." doesn't.
Quick Reference
| Pattern | Example | Verdict |
|---|---|---|
| Regex match then use | if m := re.match(p, s): | Good |
| Read-until-empty loop | while chunk := f.read(n): | Good |
| Filter on computed value | [y for x in xs if (y := f(x))] | Good when f is costly |
Witness from any() | any((hit := v) > 10 for v in xs) | Occasionally useful |
| Replace a plain assignment | (x := 5) | Don't |
| Several in one condition | if (a := ...) and (b := ...): | Usually don't |
| Running total in a comprehension | [t := t + v for v in xs] | Prefer accumulate() |
Conclusion
The walrus operator lets you assign inside an expression, which is exactly what you need for a small set of patterns: testing a regex match and using it, reading until a stream is empty, and filtering on a computed value without computing it twice. In those places it removes duplication and flattens code.
Outside them, it mostly hides assignments where readers don't expect them. Parenthesize it whenever it's part of a larger expression, compare against None explicitly when zero or empty values are valid, remember that it binds in the enclosing scope even inside comprehensions, and default to a plain = statement whenever the walrus doesn't make the code obviously clearer.


