Remember 2016? While the world was busy with Pokemon Go and the Rio Olympics, I was a wide-eyed college student, writing my very first "Hello, World!" in Python. Back then, I had no idea what dictionary order preservation meant, let alone why the Python community was buzzing about its inclusion in the upcoming 3.6 release. Now, looking back as a seasoned developer, it's amazing to see how far both Python and I have come.
From f-strings in 3.6 to the game-changing pattern matching in 3.10, and now to the free-threaded feature in 3.13, Python has consistently pushed the boundaries of what we can achieve with cleaner, more expressive code. It's like watching your favourite superhero get new powers with each movie – except instead of shooting webs or wielding a hammer, we're getting better tools to fight the real villains: code complexity and verbosity.
In this article, we're going to fire up our time machine and take a journey through the most significant features introduced in each Python version from 3.6 to 3.13. We'll look at the top features from each release, exploring how they've transformed the way we write Python code. Whether you're a seasoned Pythonista looking to reminisce or a newbie curious about the language's evolution, buckle up – we're in for an exciting ride through Python history!
By the end of this journey, you might just find yourself looking at your old code and thinking, "Wow, how did we ever live without these features?" Let's dive in and see how our favourite snake has shed its skin over the years, emerging stronger and more powerful with each transformation.
Table of Contents
- Python 3.6: The One With F-Strings
- Python 3.7: The One With Dataclasses
- Python 3.8: The One With the Walrus
- Python 3.9: The Merge Master
- Python 3.10: The Pattern Master
- Python 3.11: The Speedster
- Python 3.12: The Flexible Foundation
- Python 3.13: The Developer’s Delight
Python 3.6: The One With F-Strings
1. F-Strings: Making String Formatting Great Again (PEP 498)
If there's one feature that made Python developers collectively sigh with relief, it's f-strings. Remember the days of .format()
and %
formatting? F-strings swooped in to save us from verbose string formatting nightmares.
# The old ways
name, language, year = "Alice", "Python", 2016
print("{} started learning {} in {}".format(name, language, year)) # .format()
print("%s started learning %s in %d" % (name, language, year)) # % formatting
# The f-string way
print(f"{name} started learning {language} in {year}")
# But wait, there's more! F-strings can handle expressions
items = ["code", "coffee", "bugs"]
print(f"Developer life: {', '.join(items[:-1])} and {items[-1]}")
print(f"Hours coding today: {8 * 2}") # Math? No problem!
# They even work with method calls
message = " python rocks "
print(f"Confession: {message.strip().title()}")
2. Underscores in Numeric Literals: Because Readability Counts (PEP 515)
For those of us who deal with large numbers, this feature was a game-changer. No more counting zeros on your screen!
# Before: Is this a billion or a million? 🤔
old_budget = 1000000000
# After: Crystal clear! 👌
new_budget = 1_000_000_000
# Works with different number types
hex_address = 0xFF_FF_FF_FF # Much easier to read!
binary_flag = 0b_1111_0000 # Grouping bits
3. Variable Annotations: Hints That Don't Hurt (PEP 526)
Type hints existed before, but Python 3.6 made them more flexible with variable annotations. It allowed cleaner type hinting, paving the way for better static analysis.
# Before Python 3.6 (still works, but less flexible)
def get_user_data(user_id: int) -> dict:
pass
# Python 3.6 style
from typing import Dict, List, Optional
# Class attributes with type hints
class UserDataAnalyzer:
premium_users: List[int] = []
cache: Dict[int, str] = {}
last_analyzed: Optional[str] = None
def analyze_user(self, user_id: int) -> None:
# Some analysis logic here
self.last_analyzed = "2024-10-07"
Bonus tip: These annotations don't affect runtime behaviour - they're hints for developers and tools. But they make your IDE's autocomplete work like magic! ✨
Python 3.7: The One With Dataclasses
1. Dataclasses: Because Life's Too Short for Boilerplate (PEP 557)
Remember writing classes with a bunch of __init__
parameters and then painstakingly assigning each one? Dataclasses simplified the creation of classes by auto-generating boilerplate code like __init__
, __repr__
, and __eq__
.
from dataclasses import dataclass
from datetime import datetime
# Before dataclasses 😫
class OldBooking:
def __init__(self, id, destination, traveler, date, price):
self.id = id
self.destination = destination
self.traveler = traveler
self.date = date
self.price = price
def __repr__(self):
return f"Booking({self.id}, {self.destination}, {self.traveler})"
def __eq__(self, other):
return isinstance(other, OldBooking) and self.id == other.id
# After dataclasses 😎
@dataclass
class Booking:
id: int
destination: str
traveler: str
date: datetime
price: float
def total_with_tax(self, tax_rate: float = 0.1) -> float:
return self.price * (1 + tax_rate)
# Using our dataclass
trip = Booking(
id=42,
destination="Python Island",
traveler="Pythonista",
date=datetime.now(),
price=199.99
)
print(f"Trip cost with tax: ${trip.total_with_tax():.2f}")
2. Postponed Evaluation of Annotations (PEP 563)
This feature sounds boring but solved a major headache: enabled forward references and improved performance with lazy evaluation.
from __future__ import annotations
from typing import List
class ChessGame:
def __init__(self):
self.players: List[Player] = [] # This now works!
self.board: Board = Board() # This too!
def add_player(self, player: Player) -> None:
self.players.append(player)
def get_winner(self) -> Player | None: # Python 3.10 union type just for fun!
# Game logic here
return None
class Player:
def __init__(self, name: str, rating: int):
self.name = name
self.rating = rating
class Board:
def __init__(self):
self.moves: List[tuple[Player, str]] = []
3. Built-in breakpoint(): Debugging Made Human-Friendly (PEP 553)
Gone are the days of typing import pdb; pdb.set_trace()
. Now we can just drop a breakpoint()
and get on with our lives!
def calculate_universe_answer():
numbers = list(range(43))
breakpoint() # Your IDE probably supports this better than pdb!
return sum(numbers) - 903
def main():
print("Calculating the answer to life, universe, and everything...")
result = calculate_universe_answer()
print(f"The answer is: {result}")
# When you run this, you'll drop into a debugger at the breakpoint
# Try these in the debugger:
# - 'numbers' to see the list
# - 'len(numbers)' to check its length
# - 'n' to go to next line
# - 'c' to continue execution
Debugging Tip: Set the PYTHONBREAKPOINT
environment variable to control breakpoint behavior:
# Disable all breakpoints
export PYTHONBREAKPOINT=0
# Use a different debugger (like IPython's)
export PYTHONBREAKPOINT=IPython.embed
Python 3.7 might not have been as flashy as 3.6, but it brought some serious quality-of-life improvements. Dataclasses alone probably saved millions of keystrokes worldwide! Anything that makes debugging easier is worth its weight in gold-plated pythons.
Python 3.8: The One With the Walrus
1. Assignment Expressions (:=) - The Walrus Operator (PEP 572)
The most controversial yet powerful addition to Python. It allows you to assign values to variables as part of a larger expression.
The walrus operator allows you to do two things at once:
- Assign a value to a variable
- Use that value in a larger expression
# Consider this code example:
while True:
user_input = input("Enter something (or 'quit' to exit): ")
if user_input == 'quit':
break
print(f"You entered: {user_input}")
# We can simplify above code using walrus operator like this:
while (user_input := input("Enter something (or 'quit' to exit): ")) != 'quit':
print(f"You entered: {user_input}")
2. Positional-Only Parameters (/) - Because Sometimes Order Matters (PEP 570)
When you want to say "these args go here, no questions asked!". You can specify arguments that must be passed by position, not by keyword. This feature enhances API design flexibility and can prevent breaking changes in function signatures.
def create_character(name, /, health=100, *, special_move):
return f"{name}: {health}HP, Special: {special_move}"
# These work
player1 = create_character("Pythonista", special_move="Code Sprint")
player2 = create_character("Bug Slayer", health=120, special_move="Debug Strike")
# This fails - name must be positional
# player3 = create_character(name="Syntax Error", special_move="Crash Game")
3. f-strings Support '=': Self-Documenting Expressions
Added support for =
inside f-strings, making debugging easier.
import random
from datetime import datetime
# Debugging game stats
player_hp = random.randint(1, 100)
potions = random.randint(0, 5)
boss_hp = random.randint(50, 200)
current_timestamp = datetime.now()
print(f"{player_hp=}, {potions=}, {boss_hp=}")
print(f"{current_timestamp=:%Y-%m-%d %H:%M:%S}")
# Output:
# player_hp=9, potions=3, boss_hp=135
# current_timestamp=2024-10-18 02:20:12
The walrus operator let us write more concise code (though with great power comes great responsibility!), positional-only parameters gave us more control over our function interfaces, and f-string debugging made print-debugging actually pleasant.
Python 3.9: The Merge Master
1. Dictionary Union Operators (PEP 584)
Finally, Python gave us a clean way to merge dictionaries! Remember the days when we had to write dict1.update(dict2)
or use {**dict1, **dict2}
? Those days are behind us now.
# Your gaming inventory
inventory = {
"health_potions": 5,
"mana_potions": 3,
"gold_coins": 100
}
# You found a treasure chest!
treasure = {
"gold_coins": 50, # More coins!
"magic_sword": 1,
"dragon_scale": 2
}
# The new | operator merges dictionaries
updated_inventory = inventory | treasure
print(updated_inventory)
# Output: {'health_potions': 5, 'mana_potions': 3, 'gold_coins': 50,
# 'magic_sword': 1, 'dragon_scale': 2}
# Notice how treasure's gold_coins overwrote inventory's value
# The right-hand dictionary takes precedence!
2. Type Hinting Generics In Standard Collections (PEP 585)
This addition eliminated the need for typing.List
, typing.Dict
, etc., simplifying type annotations.
# Old way (pre-3.9) 🥱
from typing import List, Dict, Tuple
def process_user_data(
users: List[str],
scores: Dict[str, int],
coordinates: Tuple[float, float]
) -> None:
pass
# New way (3.9+) 😎
def process_user_data(
users: list[str],
scores: dict[str, int],
coordinates: tuple[float, float]
) -> None:
pass
3. String Methods: removeprefix() and removesuffix() (PEP 616)
These might seem simple, but they're incredibly powerful for text processing. No more clunky string slicing or replace()
calls with hardcoded lengths!
# Processing log files has never been easier
log_entry = "ERROR: Database connection failed!"
clean_message = log_entry.removeprefix("ERROR: ")
print(clean_message) # "Database connection failed!"
# Cleaning up file extensions
filenames = [
"photo_001.jpeg",
"photo_002.jpeg",
"not_a_photo.txt"
]
jpeg_names = [
name.removesuffix(".jpeg")
for name in filenames
if name.endswith(".jpeg")
]
print(jpeg_names) # ['photo_001', 'photo_002']
Python 3.10: The Pattern Master
Python 3.10 (released October 2021), brought some seriously awesome pattern matching features to the table.
1. Structural Pattern Matching (PEP 634)
Switch cases were so last decade. Pattern matching arrived like a Swiss Army knife for data structures. It's not just about matching values; it's about deconstructing data with the elegance of a code sommelier.
def process_api_response(response):
match response:
case {"status": "success", "data": {"users": [{"name": str(name), "age": int(age)}, *_]}}:
print(f"First user is {name}, {age} years old")
case {"status": "error", "code": 404}:
print("Not found, please check the endpoint")
case {"status": "error", "message": str(msg)}:
print(f"Error occurred: {msg}")
case _:
print("Unknown response format")
# Let's try it out
responses = [
{
"status": "success",
"data": {
"users": [
{"name": "CodingWizard", "age": 25},
{"name": "ByteMaster", "age": 30}
]
}
},
{
"status": "error",
"code": 404
}
]
for response in responses:
process_api_response(response)
# Output:
# First user is CodingWizard, 25 years old
# Not found, please check the endpoint
2. Parenthesized Context Managers - Clean Multi-Context Handling (PEP 343)
Python 3.10 introduced a clean way to handle multiple context managers using parentheses.
# Old way (pre-3.10)
def process_files(input_path, output_path, log_path):
with open(input_path, 'r') as input_file:
with open(output_path, 'w') as output_file:
with open(log_path, 'a') as log_file:
# Process the files...
# Python 3.10 way - Clean and clear!
def analyze_data(config_path, data_path):
with (
ProcessPoolExecutor(max_workers=4) as executor,
open(config_path, encoding='utf-8') as config_file,
open(data_path, encoding='utf-8') as data_file,
PerformanceMonitor() as perf_monitor,
DatabaseConnection(timeout=30) as db
):
raw_data = data_file.read()
# process data
3. Better Error Messages with Precise Line Indicators
Python decided that "AttributeError" wasn't helpful enough and opted for "Did you mean..." suggestions. It's like having a built-in code reviewer who actually wants to help rather than just point out your mistakes.
# Pre-3.10 error message for:
def calculate_score(player_stats)
return player_stats['hits'] / player_stats['attempts']
# Would show: SyntaxError: invalid syntax
# Python 3.10 shows:
# def calculate_score(player_stats)
# ^
# SyntaxError: expected ':'
# And for nested parentheses/brackets:
result = (
player_stats['hits'] /
(player_stats['attempts']
* 100
# Python 3.10 clearly shows:
# * 100
# ^
# SyntaxError: '(' was never closed
Fun fact: The pattern matching syntax was inspired by Rust and other functional programming languages, but Python made it more Pythonic. If you're coming from languages like Scala or Elixir, you'll feel right at home!
Python 3.11: The Speedster
Python 3.11 brought something we'd all been craving – serious speed improvements! This release wasn't just fast; it was "up to 60% faster than Python 3.10" fast, and 25% faster on average. But that's not all it brought to the table. Let me walk you through the most exciting features that made this version special.
1. Turbocharged Performance (PEP 659) 🚀
While this isn't a feature you can "see" in code, it's one you'll definitely feel. Python 3.11 introduced a specialized adaptive interpreter that makes your code run significantly faster. Here's a quick example to demonstrate:
# This code runs noticeably faster in Python 3.11
def calculate_fibonacci(n: int) -> int:
if n <= 1:
return n
return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)
# Time it in Python 3.10 vs 3.11
import time
def measure_performance(func, n):
start = time.perf_counter()
result = func(n)
end = time.perf_counter()
return end - start
# Running this with n=35 shows significant improvement in 3.11
# Python 3.10: ~2.5 seconds
# Python 3.11: ~1.6 seconds
The speed improvement is particularly noticeable in CPU-intensive tasks, error handling, and deeply nested function calls. It's like Python hit the gym and came back buffer than ever! 💪
2. Exception Groups and except* (PEP 654)
This feature is a lifesaver when dealing with concurrent operations where multiple errors might occur simultaneously. Instead of catching just one exception, we can now handle multiple exceptions as a group!
async def fetch_user_data(user_ids: list[int]) -> dict:
try:
async with aiohttp.ClientSession() as session:
# Imagine these API calls happening concurrently
tasks = [fetch_user(session, uid) for uid in user_ids]
return await asyncio.gather(*tasks)
except* ConnectionError as eg:
# Handle all connection errors as a group
print(f"Connection issues: {len(eg.exceptions)} failures")
for exc in eg.exceptions:
print(f"- Failed to connect to {exc.host}")
except* ValueError as eg:
# Handle all value errors separately
print(f"Data validation issues: {len(eg.exceptions)} failures")
raise # Re-raise any unhandled exceptions
3. Fine-grained Error Locations in Tracebacks
Python 3.11 improved developer productivity by pinpointing errors more precisely. It's like having a built-in debugging assistant!
def calculate_user_score(stats):
return stats.points * stats.multiplier / stats.games_played
# If stats.games_played is 0, instead of just showing:
# ZeroDivisionError: division by zero
# Python 3.11 shows:
# Traceback (most recent call last):
# File "game.py", line 2, in calculate_user_score
# return stats.points * stats.multiplier / stats.games_played
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~
# ZeroDivisionError: division by zero
# It even suggests fixes for common mistakes!
def process_data(data) # Missing colon
return data.process()
# Python 3.11 suggests:
# Did you forget a colon ':' at the end of the line?
These error messages are particularly helpful when dealing with complex mathematical operations or nested method calls. No more counting parentheses manually!
Python 3.11 wasn't just another incremental update – it was a massive leap forward in terms of performance and developer experience. The speed improvements alone make it a compelling upgrade, but throw in the new exception handling capabilities and enhanced error messages, and you've got yourself a release that truly deserves the "The Speedster" title!
Python 3.12: The Flexible Foundation
1. Enhanced F-Strings (PEP 701)
With Python 3.12, f-strings have become even better! Earlier versions had some limitations—no backslashes or comments inside f-strings, and complex expressions sometimes required workarounds.
- Backslashes can now be used inside f-strings, so you can handle escape sequences like newlines (\n) or tabs (\t) without issues.
- Comments can be added inside f-string expressions using the usual # syntax, making your code more readable and maintainable.
attendees = ["Ram", "Shyam", "Guido"]
# You can now the same type of quotes used in the beginning/end
print(f"Event Attendees:\n\t{", ".join(attendees)}")
# Or add useful comments
number = 121
print(f"""Square root of {number} is {
# You can calculate square root without using `math.sqrt` module
number**0.5
}""")
# Output: Square root of 121 is 11.0
# In python 3.11, you'd get this error on running above code:
# SyntaxError: f-string expression part cannot include '#'
2. Type Parameter Syntax (PEP 695)
You no longer need to explicitly import TypeVar
or Generic
, reducing the boilerplate and improving code readability without sacrificing functionality.
# Before Python 3.12
from typing import TypeVar, Generic
T = TypeVar('T')
class Stack(Generic[T]):
def __init__(self) -> None:
self.items = []
def push(self, item: T) -> None:
self.items.append(item)
# New in Python 3.12
class Stack[T]:
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
3. Per-Interpreter GIL (PEP 684)
One of Python's longest-standing pain points has been the Global Interpreter Lock (GIL), a mechanism that allows only one thread to execute Python bytecode at a time. This has led to performance bottlenecks in multi-threaded programs, especially for CPU-bound tasks. However, Python 3.12 introduces a significant improvement: Per-Interpreter GIL.
In simple terms, the GIL prevents Python from truly executing multiple threads simultaneously. Even though threads are often used for I/O-bound operations (like reading files or making network requests), the GIL limits the benefits of multi-threading for CPU-heavy workloads. This has long been a challenge for Python developers who need to take advantage of multi-core processors.
With Python 3.12, interpreters now have their own GIL, allowing multiple interpreters in the same process to run in parallel without being constrained by a single global lock. This is especially useful for multi-core processing. However, Python 3.12 will only support the per-interpreter GIL through the C-API. Full Python-API support will be added in Python 3.13.
More about this feature:
- A Per-Interpreter GIL: Concurrency and Parallelism with Subinterpreters
- PEP 684: A Per-Interpreter GIL
Python 3.12 might not have the immediate performance impact of 3.11, but its improvements to type system ergonomics and f-string capabilities make it a significant release for writing maintainable, type-safe code. These features are particularly valuable in larger projects where code clarity and type safety are crucial.
Python 3.13: The Developer's Delight
1. Improved Interactive Interpreter (REPL)
Python 3.13 enhances the Read-Eval-Print-Loop (REPL), making it smarter and more user-friendly. Now, REPL can execute multiple lines of code more effectively, display better syntax suggestions, and provide an improved auto-complete experience.
The new REPL has the following new features:
- Supports block-level history and block-level editing
- Automatically handles indentation when you're typing code interactively
- Browse REPL history using
F2
key - Pasting large code blocks just works (no more weird errors due to blank lines)
- Tracebacks and prompts are colorized
- You can exit the REPL just by typing exit, no need to invoke
exit()
function
2. Free-Threaded Mode - Experimental (PEP 703)
For years, Python developers have been caught in the delicate dance around the Global Interpreter Lock (GIL), a mechanism that prevents multiple native threads from executing Python bytecodes at once. While the GIL has its advantages, it's also been a bottleneck for multi-threaded applications.
The free-threading mode in Python 3.13 aims to break these chains by disabling the GIL. This allows true parallelism in multi-threaded Python programs. Essentially, your threads can now run simultaneously, making the most out of multi-core processors. In previous versions, the GIL would force these threads to run one at a time, effectively serializing the execution.
You can download the installers for macOS or Windows – they've got a free-threading option, or you can use pyenv
to build and install from source (recommended): pyenv install 3.13.0t
Note: While the free-threaded mode is a major advancement in the evolution of Python, it's important to keep in mind its experimental status (expect some bugs). Moreover, free-threaded build comes with a 40% single-threaded performance hit due to the disabled specializing adaptive interpreter (PEP 659).
3. Just-In-Time Compiler - Experimental (PEP 744)
The experimental Just-In-Time (JIT) compiler marks another significant milestone in the evolution of Python. The JIT compiler works by dynamically translating Python bytecode into machine code during runtime. It does this using a technique called "copy-and-patch". This means that frequently executed code paths are compiled on-the-fly, which can theoretically lead to substantial performance improvements for critical sections of your code.
Now, don't get too excited just yet. In its current form, the JIT compiler isn't meant to make your code faster – it's just aiming to keep up with regular Python performance. But it's doing this while adding an extra step to the process, which is pretty impressive. The Python team has big plans for this little engine, hoping to rev it up in future versions to give us some real speed gains without hogging memory. Right now, it's more about proving the concept and laying the groundwork for future optimizations.
Wrapping Up the Journey 🍁
As we mark the release of Python 3.13, one thing is clear: Python's evolution isn't just about adding features – it's about making developers' lives easier, one release at a time. It's not just about writing code; it's about writing better code, more elegantly, and with fewer headaches.
So, fellow Pythonistas, let's not rest on our laurels. The Python of today is not the Python we learned yesterday, and tomorrow's Python might surprise us yet again. Keep exploring, keep learning, and keep pushing the boundaries of what's possible with those two simple words: import this
This article was originally published on my personal blog.
Top comments (0)