1 Introduction
I’ve wanted to do this for some time now - to write about a collection of “modern” Python topics that I find really cool and useful. These are mostly topics that have gained increasing adoption within the Python ecosystem only more recently (in the last few years or so), though some have already been around for longer than that.
It’s is meant to be a teaser for each topic, not so much a full tutorial. Essentially a curated example (or two) in each section, just to get a feel for it. If you want to dive deeper into any of them, you can find great resources online (often the official documentation is your best bet, and here and there I’ll add some links too).
Why does this matter now in the age of increasingly capable AI coding assistants? I very much believe this is more relevant than ever before. AI models are amplifiers - good developers will get even better while bad ones won’t be able to benefit as much, or even worse.
Before we begin, just a quick primer on the code snippets you’ll see throughout this article.
- Read the comments, they’ll be useful.
- You’ll notice that soemtimes I have left out (the inessential) parts of the implementation, as indicated by ... - we mainly care about the design of the code here.
But no more talking now, this is all about code…
2 Chapter 02 - Modern Python Tooling
…well except for this first chapter actually, which is about tooling. However, this directly affects how you write code, setup your projects, share your code, etc. So it’s of course highly relevant and something I wanted to include here.
2.1 UV
UV is an extremely fast package and project manager for Python. You are probably already familiar with pip, conda, or poetry. UV is a drop-in replacement for all of them. It has seen light-speed adoption in the Python ecosystem since being launched just a year ago in 2024. And that did not happen for no reason - uv makes working with virtual environments and libraries incredibly simple and fast, with a speedup of 10–100x as compared to pip.
Link to docs: https://docs.astral.sh/uv/
Once installed, here’s just a couple of ways you can use it.
a) Scripts: You can just run a single script with libraries being installed on the go (yes it’s that fast). You can even add the package metadata directly to a script (thanks to PEP 723) and thereby make it fully self-contained. All you then need to do is run the script (or whoever you share the code with).
b) Jupyter: Of course you can also use uv for Jupyter notebooks (see here for detailed documentation). If needed, you can even run a standalone Jupyter notebook/lab instance and just fill your first code cell with !uv add xyz statements to realize the ultimate dream of truly self-contained notebooks that can be shared and launched within seconds.
c) Projects: However, most often you’ll want to create a full project, even if you then work in notebooks. Running the following commands will initialize a new project folder. Adding libraries to your project is done with uv add xyz. No more manual initializing of virtual environments, just cd into your project. Your .venv folder will be inside that folder and uv will just find it.
Besides all that, uv also handles all your Python versions, allows you to install and run tools (like ruff), smartly uses caching to speed up things even further, and much more.
It’s honestly just a delight to use.
2.2 Ruff
Ruff is a blazingly fast Python linter and formatter. It is developed by the same (incredible) team that also builds uv. It replaces many other code quality tools including Flake8, isort, and black.
Documentation: https://docs.astral.sh/ruff/
You can run code checks or formatting as described below.
However, rather than manually running these commands, for me ruff is most helpful as a real-time formatting and linting tool, which I use via the ruff VS Code extension, but other IDEs also have support. That way, ruff can automatically format your code as you write it (or rather on save) and it provides in-line warnings for any issues it detects.
The list of rules ruff supports to is way too long to outline here, but it includes detecting style violations and anti-patterns, sorting imports, applying a consistent formatting, and even suggesting updates for modern Python syntax. All that is pretty neat. All the code in this document has been formatted by ruff for example.
You can configure ruff within your pyproject.toml file or with a dedicated ruff.toml file. This gives you control over which rules ruff should consider or ignore.
Collaboration: One thing to call out is that if your work on a project with other developers, you’ll want to make sure all of you use the exact same formatter, so either check-in the ruff config into your git repository or set up a git pre-commit hook that applies formatting before sharing any code.
In summary, ruff requires minimal effort to set up in return for a massive gain in value. Using ruff is a no-brainer.
2.3 Mypy
Mypy is a static type checker for Python.
However, I only want to pre-tease this one here, simply because it ideally belongs in this section, but we’ll actually talk about it later, just after the section on static typing. It will make more sense then, especially if you’re not already familiar with type hints in Python.
3 Chapter 03 - Static Typing
Python is a dynamically typed language, which means that you can assign any data type to any variable. For example, you can assign an integer to x (x=1) and later overwrite the same variable with a string (x="abc"). This works because types are determined dynamically at run time in Python.
This is arguably one of Python’s biggest strength and weakness at the same time. On the one hand, it makes Python flexible and easy to learn and use. This is great for getting things done quickly, and one of the reasons Python has become so incredibly popular. However at the same type…I mean time, it also brings ambiguity and lends itself to bugs.
Since the introduction of type hints in Python 3.5 (PEP 484) static typing is now possible in Python too. However, these are only type “hints”, ie Python still does not enforce them at runtime (this is where mypy and Pydantic come in, two topics we’ll get to later on).
To type or not to type? Some argue that type hints are an abuse of Python, which was created as a dynamically typed language by design. Others say this is exactly what Python has always been missing. My personal opinion is that I find type hints very useful and I like them because (1) it aids readability, especially for function and class signatures, and (2) it forces you to be more thoughtful with your code.
You may not need them if you only work on experiments, but they do add value if you write code you want to share, reuse later, or put into production.
3.1 Basic Type Hints
Say we want to write a function setup_dataloader that contains some logic to create a PyTorch dataloader.
Note that throughout this tutorial, I will often leave out the actual implementation of the function (using …) since it doesn’t really bother us here. We mainly care about the design of the code.
Based on the above implementation, it’s not clear what exactly parameters should be. For example, does batch_size="large" work, or should it be a number like batch_size=64? Similarly for shuffle - it could be a “yes/no” or a boolean. In addition, your code editor will probably not even know what the function returns, so you won’t get autocomplete suggestions for attributes and methods of your new variable my_dataloader.
With typing, things are much clearer.
Now it’s clear that what we expect is an integer for batch_size and a boolean for shuffle. We now also know the function returns a PyTorch Dataloader object.
Below is a summary of basic type hints.
Note that type hints are most useful for parameters in functions or methods, and for class attributes. For simple variables as in x=123, typing is typically unnecessary since it is perfectly clear that x is an integer here.
3.2 Literals
Whenever you have a parameter that should only take on one of a few values, you can use a Literal. Here is an example that contains two literals, one for environment and one for batch_size:
3.3 Enums
Similarly to literals, we can also create a dedicated Enum, which is a specific data type for a categorical variable. A StrEnum for example essentially behaves like a string, and an IntEnum like an integer.
We can then use them in multiple places so we don’t have to repeat ourselves. This also helps prevent bugs as you need to be more specific and upfront about what values you accept, and you can access its values using dot syntax.
Enums are really great (and surprisingly fun to write). However, at the same time it’s easy to overuse them. In the example above, for instance, I would argue that using a Literal, not enums, is the better choice because they will actually display for you their values in the function signature whereas enums do not.
3.4 Callable
Let’s take typing a step further with the Callable type hint, which describes a function.
The callable we use below for the transformation parameter describes a function with a single argument of type float, and a return value of type float, ie it’s signature follows (float) -> float.
What is the benefit here? Note how we have made the transform_data agnostic to whatever function we eventually want to apply to the data, which may be a log, square, cap, etc. Using the callable we only defined a “contract” that the function must adhere to. This example is actually also reminiscent of the “strategy pattern”, which we’ll get to later on.
3.5 Mypy
Now that we covered basic type hints, we can finally talk about mypy. Mypy is a static type checker for Python.
As already explained, Python does not enforce type hints at runtime. They are merely “hints”, mainly for you and your IDE. This is where mypy comes in. As a static type checker, mypy verifies type consistency before execution, catching a whole range of bugs that would otherwise only appear at runtime. Essentially, mypy takes all your type hints and looks for any inconsistencies, giving you warnings or errors for anything it finds.
Mypy has also excellent IDE support (for example in VS Code), so you can install the extension and see these warnings as you type.
Here’s just a simple example of a function that does not fully adhere to what the type hints specify.
Quick side note: Guess what? Astral, the team who already built uv and ruff, are also working on “ty”, a static type checker for Python. You can expect that to be a pretty good option too once it’s ready for broad use.
4 Chapter 04 - Typed Data Structures
Simple type hints are already great, but you can use them also to create more comprehensive data structures.
4.1 Typed Dictionaries
Typed dictionaries essentially behave and look like regular dictionaries, but incorporate type hints. That means they come with clear expectations about keys and values, unlike regular dictionaries. Below, for example, we create a typed dict Metrics that represents some model eval results. The advantage is that we define exactly what any instance of this dictionary will look like, so the receiver can be more confident in further processing it.
4.2 Dataclasses
Dataclasses are another great Python feature. They have been around for some time now (since 2015 I think), but they are absolutely great for many use cases, and their popularity has grown recently.
The simple @dataclass decorator reduces much of the boilerplate code needed to create classes (eg the __init__ method), letting you focus on the data structure.
Below is an example of a config dataclass. We set frozen=True here to ensure that any instance of it isn’t (accidentally) modified later (using immutable data types is often a good idea, as we’ll discuss later). from dataclasses import dataclass
Dataclass or TypedDict? Dataclasses are great - that’s all I want to say here.
4.3 Nested Structures
Dataclasses and typed dictionaries both allow you to create nested data structures. This is very useful, but don’t create too heavily nested structures for the sake of simplicity.
At this point it’s also worth mentioning that you can easily take this too far to the point where only pass around objects. For example, you may create functions that have just a single (or a few) abstract arguments, which contain the actual values the function needs (like the model_config above). This can make your functions harder to use rather than easier, if not used thoughtfully and sparingly. It takes some experience to decide where abstract inputs make more sense and what should just be native function parameters (ie str, int, etc).
5 Chapter 05 - Pydantic
Pydantic is a data validation (and serialization) library. In the last few years, Pydantic has taken the Python ecosystem by storm, with many major libraries existentially relying on or building on top of it, notably for example FastAPI or the the OpenAI SDK.
Unlike static type checkers, Pydantic enforces type annotations through runtime validation. That means that you can trust your data is in the right format once it’s validated by your Pydantic data model.
I would argue that the popularity of Pydantic is the final proof that the community at large has embraced typing and type safety in Python. And it’s also just incredibly fun to create Pydantic models - this hard to describe, it’s just enjoyable to use. I think you’ll see why when you try it out. This cannot be a full intro to Pydantic, since it’s a very comprehensive library. But we’ll cover some key topics.
Link to their (absolutely great) docs: https://docs.pydantic.dev/latest/
5.1 Pydantic Models
Say we want to build a data structure for a product. We may want to ensure that price cannot be below 0, and that the production date occurs before the shelf date. Both could be the result of mistakes (or even manipulation) and lead to bugs.
5.2 Validation Decorator
Pydantic also makes it really easy to validate inputs of a function. Just use their validate_call decorator. Just as with Pydantic models, validation occurs at runtime.
5.3 Type Annotations
Type annotations were added in Python 3.XY. The main idea is to append some metadata to a type. They’ve become a common tool for various different use cases.
For example, in combination with Pydantic, they can be used to create custom, validated data types. Below, we’ll define a noneEmptyStrType and a roundedFloatType. These types can then be used just like any other types (eg str, int, etc), and runtime validation is just baked int. In the case of the roundedFloatType, it’s actually not so much validation as it is post-processing (ie rounding the value). Here’s how this works (see code below):
Say we pass a wrong data type, like the string "19.49" - Pydantic then first attempts to cast the string into a float (that’s just part of Pydantic’s validation). If that works, it then applies the AfterValidator which rounds the float to 19.5.
6 Chapter 06 - Software Design Patterns
6.1 Protocols
Ever heard of duck typing? It’s one of Python’s (and other languages’) most ubiquitous features. It’s is captured in the saying: “If it walks like a duck and quacks like a duck, then it might as well be a duck”. This is useful because often you don’t really care what an object is, but rather what it can do.
Enter protocols. Using a Protocol, you can specify an “interface”. Any objects that implement this interface, ie its methods and/or attributes, are considered compatible. In fact, this is somewhat similar to an abstract base class that defines some abstract methods, and then inheriting from it. Except that protocols do not require inheritance at all.
This is best explained using an example.
Say we want to recommend items from among a mixed pool of products and services. We may decide that all we need to do this is for each item to just have some kind of relevance score (computed elsewhere) that we can use to rank them, no matter what else they may contain. So let’s define a new protocol Recommendable which matches any object that contains a relevance attribute. It’s important to recognize how this is all we really care about here - we can recommend totally different items as long as they have that attribute.
When can then define a recommendation function that expects a list of Recommendable. The function is now flexible enough to recommend from among any set of items as long as they implement the right interface. The Recommendable is only a “contract” or “promise” so to say, which helps us make explicit what the function really needs.
6.2 Structural Pattern Matching
If you’ve seen match/case statements in Python, they may seem like if - else replacements initially, but they are much more than that.
You should also read the original proposal for PEP 636, which is really good and highly readable: https://peps.python.org/pep-0636/
Let’s say we now want to ship our product or service (which we both defined above), but we need to apply a different shipping logic for heavy products, light products, and services.
The first case statement below matches on objects of type Product but only if it also has a weight of at least 5.0. The second matches all other products, the third one any services, and finally _ is just a catchall case.
Structural pattern matching is quite powerful if used in the right places.
6.3 Dependency Injection
This is a fairly useful principle that we’ve already implicitly applied many times throughout this tutorial. But here we go more formally.
Definition: Dependencies should be injected rather than created internally. This is also related to the open-closed principle, which says that “your code should be open to extension but closed to modification.”
What this means in practical terms is that you should pass dependencies as arguments to functions/methods (or as attributes to classes), rather than initialize them from within. That reduces tight coupling and increases modularity.
Many modern and popular Python libraries make heavy use of dependency injection, including FastAPI, pytest, and many of the recent AI frameworks like PydanticAI and OpenAI’s Agent Framework.
Below is another example, this time using a dataclass.
6.4 Factory Pattern
I’ve come across the factory pattern and just liked it so I wanted to include it here too, even though it’s definitely not a “modern” Python topic - it has been a classic software design pattern for many decades. The factory pattern is relevant when you want to help the user create objects, as an alternative to instantiating them directly, for example because some additional logic needs to be applied.
Let’s say you receive a list of dictionaries from an API or perhaps from a language model which can be either products or services. We can create an item_factory function that takes a single dictionary and returns either a Product or Service object, depending on its “type” attribute.
One problem with this implementation is that we will have to edit our item_factory() if we create a new item type (say a “Subscription” type) - this violates the “open-closed principle”. But I think that’s ok here, especially for illustrative purposes. You can always be more abstract, but that in itself is not a good goal.
Another really good and simple approach to achieve the same here would be to use a Pydantic TypeAdapter, which will run through all possible types and choose the (first) one that successfully validates.
6.5 Strategy Pattern
In my own words, the strategy pattern allows you to do the same thing in different ways. Like the factory pattern, it’s not new, but I do find it very useful and wanted to include it. Plus, we’ve already seen the strategy pattern a couple of times before in this tutorial, so it makes sense to formally describe it here.
Say you have a “basket” of charities that you want to donate to. You have a budget but how do you distribute it among the charities? You have different options.
- equal allocation: divide budget equally across all charities
- by relevance: proportional to a personal relevance score
- randomly: each item gets a random share of the budget
These are the different “strategies” here.
In my code below, they are implemented as functions that follow a specific signature (ie (Basket) -> list[float]), defined using a Callable. Each strategy functions only creates a weights vector that defines what share of the budget should go to which charity. When we call the distribute_budget function, it uses the strategy to get the weights and then handles the actual distribution of the budget.
You can also implement the strategies using classes (often with the help of inheritance and/or protocols), but we’ll just keep it simpler and functional here. And hey, functional programming is very much in vogue and a good idea in lots of cases.
Minor note on why we have a set_donation() method for the Charity class. This is an example of the “ask, don’t tell” principle. It’s better to have a class control how variables are set rather than manipulate them directly from outside (eg from the Basket class).
6.6 Functional Programming
Form recent talks and posts that I’ve come along, I gathered that many people feel that the use of object oriented patters in Python may have been taken too far at times.
There has been a recent sense that writing less abstract code and just keeping things simple may just be fine, in a lot of cases. Protocols, for example, are one feature that allows you to get rid of inheritance and instead use flatter hierarchies and duck typing. There’s also a bit of a renaissance of functional programming in Python. While this could be it’s own full tutorial, there are only really a couple of topic that I’d like to touch on here.
Pure Functions: The first one is “pure” functions and why you should use them. Pure functions don’t mutate their input, and they don’t have any side effects. They are deterministic, simpler to understand, easier to test, easier to debug, and harder to break. That sounds pretty desirable, right?
A function’s purpose: A function should do one thing only, and it should do it well. If a function does more than one thing, you may want to rethink the design. Of course take this with a big grain of salt - you definitely don’t want to end up with a mess of functions, and there is at least one place where you HAVE TO combine the loose ends. But anyway, I believe that aiming for a clear purpose of each function is important to keep in mind.
Function compositions: The final topic here is what you may call pipelines, function composition, or function chaining.
Here’s an example. Let’s say we want to implement an “agentic search” feature consisting of three components. That is, given the user’s input, (1) a language model first performs a query expansion (ie writes a search query). Then, (2) the expanded query is sent to a vector store to retrieve the most relevant documents. And finally, (3) we want to format the search results nicely.
So overall we want to chain the following three steps, which we can formalize using callables.
- Agent is a callable that takes a str (the user input) and returns a str (the new search query).
- Searcher is a callable that takes a str (the agent’s search query) and returns a list of str (the search results).
- Formatter is a callable that takes a list of str (the search results) and returns a str (a formatted version).
All that we want to combine into a single AgenticSearch function. This function will correspond to a callable that simply takes a str (the user input) and returns a str (the formatted search results). From the outside, this will look like a simple function, but we know that internally, a lot happens.
The benefit is that we can now easily change any of the components and still have a working agentic search.
This was probably the most complex example in this tutorial. It can also be implemented using an OOP design. In this case, we would then an Agent, a Searcher, and a Formatter protocol or base class. And our AgenticSearch would be an class that will be initialized with one of each.
7 Conclusion
I hope you enjoyed reading this tutorial as much as I did writing it. In fact, while working on it, I noticed that there are almost no Python tutorials covering “modern” topics that aren’t either beginner-level or are just covering one specific topic. So I wanted to change that.
My goal here was just to give a taste of what’s out there and how these tools and techniques can make your Python life a bit easier and your code a bit better. My suggestion is to take away from it whatever you find interesting and learn more about it, for example by reading the official docs or another tutorial.
Or… and this leads me to the last bit of this tutorial, you can also collaborate with AI tools to see how you can apply them to your code.
7.1 My Advice on using AI for Coding
There’s a lot of talk around how AI is reshaping coding and software engineering, and given the rapid progress, I can only do as much as give my opinion on what works best for me right now (or not).
a) Code completions: First of all, AI in-line code completions - as in the likes of GitHub Copilot - are pretty neat and for many developers now an indispensable part of writing code. They are indeed a big step up from the kind of completions that a linter can provide. But I’d still call this an incremental improvement over what we had before LLMs. You can do far more with them than that.
b) Vibe coding: However, I’m also not a fan of taking it as far as vibe coding. It’s cool that this increasingly works, but there are also still some fundamental limitations. And just having a lot of code you don’t really understand won’t cut it if you also want it to work reliably, be maintainable, etc.
One of the limitations that is barely ever talked about (not sure why) is the fact that a coding model won’t necessary know how to write “modern” code. Say a new great library or feature gets released today, your model won’t know know about it and currently cannot just “learn it” like a human can.
c) Code editing: This is something I cannot say too much about, but the idea here is to use a language model to navigate a large existing code base and use it to make incremental code edits, implement feature requests, or solve a GitHub issue. Even though I haven’t tried this much so far, this is definitely another area where AI assistants will become increasingly useful.
d) AI as your software design helper: Most of all, I prefer using AI to help me think through code design choices. This is what I think provides the most value to me right now. In fact, I rarely ever just ask an AI to write large chunks of code all by itself directly. Instead I typically ask for an outline or an example that I can then implement in a way that works for me.
Another tip I have is to ask for options rather than a single solution. I think that’s incredibly powerful - you can only judge a solution based on its alternatives, and this allows you weigh up and understand different options.
For example, I often use questions like:
- “As an experienced software engineer, give me some feedback about the design of my code here. Is there anything that you’d do differently.”
- “Tell me about dependency injection and how I would use it here. Show me different options.”
- “I don’t like that I have xyz as a parameter of the function. What can I do to solve that?”
- “Imagine you had to (re-)design ABC…”
7.2 Additional Topics
There are a few other cool topics that I would have liked to include here but this is already a pretty comprehensive tutorial. These are some of them:
- Asyncio: writing asynchronous code in Python
- Pytest: a popular testing framework
- FastAPI: for building APIs
- SQLModel: ORM for SQL databases
- Modern Dataframes: Polars and DuckDB
- AI Agent Frameworks: Pydantic AI, Semantic Kernel, OpenAI Agents etc.
- etc.
.png)

