Python: Walrus Operator & Modern Features (3.8-3.14)

You compute a value, check it, then use it. Three lines, and you end up calling the same function twice or stashing the result in a throwaway variable. The Python walrus operator := lets you do all three in one step, and it is just the start of a whole batch of modern Python features (dict merge with |, removeprefix(), tomllib, ExceptionGroup, and free-threaded mode) that quietly make your code shorter and clearer.

“Easy things should be easy, and hard things should be possible.”

Larry Wall, Programming Perl

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 15 minutes

Python grows one small comfort at a time. Between 3.8 and 3.14, the language picked up assignment expressions (the walrus operator), dictionary merge operators, structural pattern matching, exception groups, a built-in TOML (Tom’s Obvious Minimal Language) parser, and a free-threaded build that finally lets threads run Python code in parallel. None of these are flashy. Each one just removes an ugly workaround you used to write by hand, over and over.

Think of this post as a recipe card, not a changelog. We will skip the trivia and stick to the features that change how you write Python on a normal Tuesday. The Python walrus operator is first, because it kills a pattern you have typed hundreds of times: work out a value, test it, then reuse it.

Most Impactful FeaturesWalrus Operator :=Python 3.8: Walrus :=operatorPositional-only params /Python 3.9: dict merge |built-in generics,str.removeprefixPython 3.10: match/caseUnion X | Y syntax, bettererrorsPython 3.11:ExceptionGroupStrEnum, tomllib, 10-60%fasterPython 3.12: type statementf-string nesting,per-interpreter GILPython 3.13: improved REPLfree-threaded mode, JITcompiler expAssign inside expressionsif n := len data 10:while line := f.readline :match/case 3.10Structural pattern matchingX | Y union 3.10Cleaner type hints:= walrus 3.8Assign in expressionsPython Walrus Operator: Modern Feature Timeline from 3.8 to 3.13

The diagram lines up the walrus operator (:=, Python 3.8) on a timeline next to the other modern Python features: positional-only parameters (3.8), the dict merge operator (3.9), pattern matching (3.10), exception groups (3.11), and the free-threaded build that started as an experiment in 3.13. In Python 3.14 that free-threaded build became an officially supported option, which we cover near the end. Knowing which version added which feature is practical, not trivia: it tells you whether your code will run on the Python versions your team actually deploys.

The Python Walrus Operator := (3.8)

Here is the pain. You call a function to get a value, you check that value in an if, and then you want the value again inside the block. So you either call the function a second time (wasteful) or you create a temporary variable on its own line above the if (clutter). The Python walrus operator := assigns and checks in the same breath, so the value is captured exactly where you test it.

Picture the self-checkout machine at a grocery store. It scans the item and shows the price at the same moment. It does not scan once to read the barcode, then scan again to show the price. One pass, both results. That single pass is what := gives you: name the value and use it in one move.

📄 walrus.py: assign inside expressions

# Before walrus: compute twice, or park the result in a temp variable
data = "this is a long string"  # pretend this came from input() or a file
if len(data) > 10:
    print(f"Data too long: {len(data)} chars")  # len() runs a second time here

# After walrus: compute once, then reuse the captured result
if (n := len(data)) > 10:
    print(f"Data too long: {n} chars")

# While loop pattern: the classic walrus use case
import io
stream = io.StringIO("line1\nline2\nline3\n")
while (line := stream.readline()):
    print(f"Got: {line.strip()}")

# Comprehension that filters and transforms in one shot
numbers = [1, 15, 3, 22, 8, 17, 4]
results = [squared for x in numbers if (squared := x ** 2) > 100]
print(results)

▶ Output

Data too long: 21 chars
Data too long: 21 chars
Got: line1
Got: line2
Got: line3
[225, 484, 289]

What happened here: The first two prints come from the same check written two ways. The plain version calls len(data) twice, once in the if and once in the message. The walrus version writes (n := len(data)), which stores the length in n and compares it in a single step, so len() runs only once. The while loop reads a line, assigns it to line, and stops when readline() returns an empty string (which is falsy), so there is no clumsy “read once before the loop, read again inside it” dance.

In the comprehension, (squared := x ** 2) computes the square, names it, filters on it, and reuses that same value in the output list. No double squaring.

Dictionary Merge Operators (3.9)

Combining two dictionaries used to mean {**a, **b} (cryptic) or a.copy() followed by a.update(b) (two lines). Python 3.9 gave dictionaries the same | you already know from sets. Read defaults | user_prefs as “start with the defaults, then let the user preferences win on any key they both have.”

📄 dict_merge.py: | and |= for merging dicts

defaults = {"theme": "dark", "language": "en", "page_size": 20}
user_prefs = {"theme": "light", "font_size": 14}

# Merge into a new dict: on a shared key, the right side wins
config = defaults | user_prefs
print(config)

# In-place merge: defaults absorbs user_prefs, no new dict created
defaults |= user_prefs
print(defaults)

▶ Output

{'theme': 'light', 'language': 'en', 'page_size': 20, 'font_size': 14}
{'theme': 'light', 'language': 'en', 'page_size': 20, 'font_size': 14}

What happened here: Think of it like default settings on a new phone. The phone ships with a theme and language already chosen, then you change a few of them. Your choices override the factory ones, but the settings you never touched stay as they were. That is exactly what | does. theme flipped from "dark" to "light" because the user set it, font_size got added because it was new, and language and page_size kept the defaults. The plain | builds a brand new dict and leaves the originals alone. The |= version skips the copy and updates defaults in place, which is why the second line prints the same result.

removeprefix() and removesuffix() (3.9)

For years, people reached for lstrip() and rstrip() to chop a prefix or suffix off a string, and for years it bit them. Those methods do not remove a string. They remove any character in the set you pass, from the ends, one by one. So "test_calculator.py".lstrip("test_") strips every leading t, e, s, and _, which only looks correct by accident. Python 3.9 added removeprefix() and removesuffix(), which remove the exact string and nothing else.

📄 string_new.py: clean prefix and suffix removal

filename = "test_calculator.py"

# Before 3.9, people used lstrip, which removes individual characters:
# filename.lstrip("test_")  # WRONG: strips t, e, s, _ one at a time

# After 3.9, removeprefix and removesuffix match the exact string
print(filename.removeprefix("test_"))    # calculator.py
print(filename.removesuffix(".py"))      # test_calculator
print("hello".removeprefix("xyz"))       # no match, so the original comes back

▶ Output

calculator.py
test_calculator
hello

What happened here: It is like peeling a sticker off a jar. removeprefix("test_") peels off exactly that label and leaves the rest untouched, so you get calculator.py. removesuffix(".py") peels the extension and gives test_calculator. The last line shows the safe behavior people often forget: when the prefix does not match ("xyz" is not at the front of "hello"), the method returns the original string unchanged instead of raising an error. That is why these two methods quietly replaced the old lstrip and rstrip trick in clean code.

tomllib and ExceptionGroup (3.11)

Python 3.11 shipped two things worth knowing here. First, tomllib: a TOML reader built right into the standard library, so you can parse a pyproject.toml without installing anything. Second, ExceptionGroup with the except* syntax, which lets one block raise several errors at once and lets the caller sort them by type. Think of a teacher correcting your exam in one pass and marking every mistake on the page, instead of handing it back over and over with a single error circled each time. That is what an exception group does for error reporting. The syntax is a bit unusual, so read the code slowly.

📄 py311.py: tomllib and ExceptionGroup

# tomllib: a built-in TOML parser (3.11), read-only by design
import tomllib

toml_str = b"""
[project]
name = "my-app"
version = "1.0.0"

[project.dependencies]
requests = ">=2.28"
"""

config = tomllib.loads(toml_str.decode())
print(config["project"]["name"])      # my-app
print(config["project"]["version"])   # 1.0.0

# ExceptionGroup: raise several exceptions at once (3.11)
try:
    raise ExceptionGroup("validation errors", [
        ValueError("Name is required"),
        TypeError("Age must be an integer"),
    ])
except* ValueError as eg:
    print(f"Value errors: {eg.exceptions}")
except* TypeError as eg:
    print(f"Type errors: {eg.exceptions}")

▶ Output

my-app
1.0.0
Value errors: (ValueError('Name is required'),)
Type errors: (TypeError('Age must be an integer'),)

What happened here: tomllib.loads() turned the TOML text into a plain nested dictionary, so reading the project name is just normal dict access. One catch worth remembering: tomllib only reads TOML, it does not write it. For writing you still need a third-party package such as tomli-w. The ExceptionGroup part is the interesting bit. Imagine a form that checks every field and reports all the problems at once, instead of stopping at the first one.

The group bundles a ValueError and a TypeError together, and the new except* (with the star) catches each type separately. So the value error and the type error land in different handlers, even though they were raised in the same breath. The eg.exceptions attribute holds the matched errors as a tuple, which is why each line prints a one-item tuple.

Free-Threaded Python (3.13 to 3.14)

For most of Python’s life, the Global Interpreter Lock (the GIL) meant only one thread could run Python code at a time. The free-threaded build removes that lock so threads can run in true parallel. It first appeared as an experiment in Python 3.13. In Python 3.14, Python Enhancement Proposal (PEP) 779 promoted it to an officially supported build, though it is still opt-in and not the default. You get it by installing the special “t” build (for example python3.14t), not by flipping a switch in your normal interpreter.

Think of the GIL like a single bathroom key in an office. Only one person can hold it at a time, so everyone queues, even when there are several bathrooms. The free-threaded build hands out a key per bathroom, so people stop waiting on each other. The code below just asks the interpreter which kind of build it is.

📄 free_threaded.py: is the GIL on or off?

import sys

# True on the normal build, False on the free-threaded "t" build
print(f"GIL enabled: {sys._is_gil_enabled()}")

▶ Output (standard Python 3.14.6 build)

GIL enabled: True

What happened here: We ran this on the standard Python 3.14.6 build, the one almost everyone installs, so the GIL is on and sys._is_gil_enabled() reports True. Run the very same line on the free-threaded build (python3.14t) and it prints GIL enabled: False instead, because that build ships without the lock. The free-threaded installer is an optional checkbox in the official Windows and macOS installers, and on Linux you build it with ./configure --disable-gil.

Two honest cautions before you reach for it: it is genuinely supported as of 3.14 but still not the default, and single-threaded code can run a little slower on it, so use it when real thread parallelism is the whole point, not as a free speed boost.

Common Mistakes

Mistake 1: Cramming several walruses into one line

❌ Overusing the walrus operator

# BAD: three walruses in one condition, nobody can read this at a glance
if (x := f()) and (y := g(x)) and (z := h(y)):
    process(x, y, z)

# GOOD: one walrus, for the single expression that actually benefits
if (match := pattern.search(text)) is not None:
    print(match.group())

Why: The walrus operator is a scalpel, not a hammer. The bad version chains three assignments inside one if, so a reader has to untangle the order of f, g, and h just to see what is being tested. At that point a few plain lines above the if would be clearer, and clearer wins. The good version uses := for exactly the case it was designed for: run a search, keep the result, and check it in one place.

Rule of thumb: if a second walrus on the same line makes you pause, stop and use normal assignment. (Both snippets above are style illustrations, so they assume f, g, h, pattern, and text already exist.)

Mistake 2: Using lstrip to remove a prefix

❌ lstrip strips characters, not a prefix

# BAD: lstrip removes any of t, e, s, _ from the front, one by one
print("test_test_data.py".lstrip("test_"))   # 'data.py', and only by luck

# GOOD: removeprefix takes off the exact string, once
print("test_test_data.py".removeprefix("test_"))  # 'test_data.py'

▶ Output

data.py
test_data.py

Why: This is the trap from earlier, shown side by side. lstrip("test_") keeps eating leading characters as long as each one is in the set {t, e, s, _}, so it chews straight through both copies of test_ and lands on data.py. removeprefix("test_") strips the literal test_ exactly once and stops, which is almost always what you meant. Reach for removeprefix and removesuffix whenever the thing you want to remove is a whole word, not a bag of characters.

Try It Yourself

Open a Python 3.14.6 prompt and work through these. They take the patterns from this post and make you reach for them on your own.

  1. Exercise 1: Write a while loop that keeps asking for input with input() and stops the moment the user types "quit". Use := so you read and test in one line.
  2. Exercise 2: Say three students named Anvi, Rahul, and Aditi just took a quiz: scores = {"Anvi": 95, "Rahul": 72, "Aditi": 88}. Build a list of just the names whose score is above 80, using a list comprehension with := so you look up each score only once.
  3. Exercise 3: Take two dictionaries, a set of base settings and a set of overrides, and merge them three ways: with {**a, **b}, with a | b, and with a |= b. Note which ones leave the originals untouched and which one changes them.

Conclusion

You now have the modern Python toolkit that separates 3.8-era code from 3.14-era code: the Python walrus operator := for assigning inside expressions, | and |= for merging dictionaries, removeprefix() and removesuffix() for safe string trimming, tomllib for reading TOML, except* with ExceptionGroup for handling several errors at once, and the free-threaded build for true thread parallelism. None of these are mandatory, but each one deletes a workaround you no longer have to write. Up next, we go under the hood: how Python manages memory, what the garbage collector actually does, and why the GIL existed in the first place. For the full learning path from beginner to AI/ML, visit the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is the walrus operator in Python?

The Python walrus operator := (officially the ‘assignment expression’) assigns a value to a variable as part of a larger expression. if (n := len(data)) > 10 stores len(data) in n and checks whether it is greater than 10, all in one step, so you do not call len() twice.

Why is it called the walrus operator?

Because := looks like a walrus lying on its side: the colon is the eyes and the equals sign is the tusks. The nickname stuck during the PEP 572 discussions that added the feature in Python 3.8.

What is free-threaded Python in 3.14?

It is a separate build of Python that runs without the Global Interpreter Lock (GIL), so threads can run Python code in true parallel. It started as an experiment in 3.13 and became an officially supported, opt-in build in Python 3.14.6 (PEP 779). It is not the default, and single-threaded code can be a little slower on it, so use it when you need real thread parallelism.

Should I use removeprefix or lstrip to remove a prefix?

Always use removeprefix() (3.9+). lstrip() removes individual CHARACTERS in the set you pass, not a prefix string. 'test_file'.lstrip('test_') strips t, e, s, t, _ one at a time and returns 'file' only by coincidence, while removeprefix('test_') removes the exact prefix once.

What is the dict merge operator?

The | operator (3.9+) merges two dictionaries: a | b returns a new dict with all keys from both, and b‘s values win on any shared key. |= is the in-place version that updates the left dict directly.

Interview Questions on the Walrus Operator and Modern Python

If you can walk through these without peeking, you are ready for this topic in an interview.

Q: A teammate writes x := 10 on its own line and gets a SyntaxError. Why, and what is the fix?

The walrus operator is an assignment expression, not an assignment statement, and PEP 572 deliberately forbids using it unparenthesized as a standalone statement. On its own line you should write plain x = 10. The walrus only earns its keep inside a larger expression, such as an if condition, a while condition, or a comprehension, where you want to name a value and test it in the same step.

Q: How does the scope of a walrus target inside a list comprehension differ from the loop variable?

The comprehension loop variable (the x in for x in ...) is local to the comprehension and disappears afterwards. A walrus target inside the same comprehension deliberately leaks to the enclosing scope, so after [y for x in data if (y := f(x)) > 0] runs, y still exists outside and holds the last value assigned. This is by design in PEP 572, and it is worth knowing because it can silently overwrite a variable of the same name in the surrounding function.

Q: Why does while chunk := f.read(8192): terminate cleanly without an explicit break?

At end of file, read() returns an empty string or empty bytes, both of which are falsy in Python. The walrus assigns that result to chunk and the while immediately evaluates it, so the loop exits on the first empty read. This replaces the old pattern of reading once before the loop and again at the bottom of it, which duplicated the read call and was a classic source of off-by-one bugs.

Q: What does except* do differently from a plain except?

A plain except matches the raised exception against handlers and runs at most one of them. With except* (3.11+), Python splits an ExceptionGroup by type, so a single raise carrying both a ValueError and a TypeError can trigger the except* ValueError handler and the except* TypeError handler in the same pass. Inside each handler, eg.exceptions is a tuple holding just the matched errors. This is built for code that gathers many failures at once, like validating every field of a form or supervising concurrent tasks.

Q: Your service loads settings with tomllib and now needs to save updated settings back to the same TOML file. What do you tell the team?

That tomllib is read-only by design: it has load() and loads() but no dump() or dumps(), so writing needs a third-party package such as tomli-w. Also remind them that tomllib.load() requires the file to be opened in binary mode ("rb"), not text mode, or it raises a TypeError. If the settings must be written frequently, it may be worth asking whether JSON, which the standard library can both read and write, is the better format for that file.

Q: You move a Central Processing Unit (CPU)-bound multithreaded service to the free-threaded python3.14t build expecting a speedup, but your single-threaded benchmarks got slower and one C extension crashes. What do you check first?

Both symptoms are expected, not mysterious. The free-threaded build trades some single-threaded speed for the ability to run threads in true parallel, so code that does not actually use multiple threads can run a little slower on it. For the crash, check whether that C extension has been rebuilt and declared compatible with the free-threaded ABI: extensions written for the GIL era can assume the lock protects their internal state, and running them without it is unsafe. The practical checklist is: confirm the workload is genuinely multi-threaded and CPU-bound, verify every native dependency supports free-threaded builds, and benchmark both builds before committing.

Want more? the official Python documentation documents everything this post could not fit.

Previous: Python: Pattern Matching with match/case (3.10+)

Next: Python Memory Management: References, Garbage Collection, and the GIL

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *