Python
The data model, the mutable default, the GIL, and the parts of the language that catch experienced people.
Everything is an object
beginnerPython has one uniform rule underneath its syntax: every value is an object with a type, and every operator is a method call. Adding two numbers calls __add__, indexing calls __getitem__, len calls __len__, and a for loop calls __iter__ and then __next__ until it is told to stop. Once you know that, the language stops having special cases and starts having a small set of protocols.
This is what makes user-defined types feel native. Implement __len__ and __getitem__ and your class works with len, indexing, slicing and iteration. Implement __enter__ and __exit__ and it works with with. There is no interface to declare and nothing to inherit from; the method being present is the entire contract, which is duck typing made explicit.
Names are bindings rather than boxes. Assignment binds a name to an object; it never copies. Two names can refer to the same list, and mutating through one is visible through the other. The identity operator is checks whether two names refer to the same object, while == asks the objects whether they are equal, and confusing the two produces bugs that appear to depend on the value being tested.
Memory is managed by reference counting plus a cycle collector. An object is freed the moment its last reference goes away, which is why files closed by scope exit usually work in CPython and are not guaranteed by the language. Reference cycles need the collector, which runs periodically, so a class with __del__ and a cycle can keep memory alive far longer than the code suggests.
The protocols are the language. Learning the dunder methods turns Python from a collection of conveniences into a system where your own types behave exactly like the built-in ones, which is the difference between writing Python and writing another language in Python syntax.
A class implements __len__ and __getitem__. What does it get for free?
Mutability and the default argument trap
intermediateA default argument is evaluated once, when the function is defined, not each time it is called. A function declared with an empty list as a default therefore shares one list across every call that omits the argument, so items appended in one call are visible in the next. It looks like the function is remembering things, and in a sense it is.
The fix is the idiom you see everywhere: default to None and create the real value inside the body. It looks like ceremony until you know why it exists, at which point it stops looking like a style choice.
The same underlying fact, that names bind to objects rather than copy them, explains most other surprises. Slicing a list gives you a shallow copy, so the outer list is new and the inner objects are shared; copy.deepcopy exists for when that matters. A tuple is immutable in that its bindings cannot be changed, and a tuple containing a list still lets you mutate that list, which is why a tuple of mutable objects is not hashable in the way people expect.
This is also where class attributes catch people. An attribute assigned in the class body belongs to the class, so every instance shares it, and mutating it through one instance changes it for all of them. Assigning to it through an instance creates a new instance attribute that shadows the class one, so the two look identical in code and behave differently, which is a genuinely difficult bug to see by reading.
Every one of these is the same fact wearing a different hat: Python binds names to objects and does not copy. Learning the rule once means recognising the pattern in the default argument, the shallow copy, the shared class attribute and the tuple that is not as immutable as it looks.
Why does a function with an empty list as its default argument appear to remember values between calls?
Comprehensions, generators and laziness
intermediateA comprehension builds a collection in one expression, and its value is not brevity but that it says what is being built rather than how. A list comprehension produces a list immediately; a generator expression, with parentheses instead of brackets, produces an iterator that computes each item when asked, which is the difference between holding a million rows in memory and holding one.
Generators are the cheapest performance tool in the language for anything sequential. A function with yield in it returns a generator: it runs until the first yield, hands back a value, and resumes where it left off when the next value is requested. That turns a pipeline of transformations into something that streams, so a five gigabyte file can be processed in constant memory by a function that reads like it processes a list.
The cost is that a generator can only be consumed once, and its laziness moves work later, so an exception can surface in the loop that consumes it rather than in the line that appeared to create it. It also means the timing of side effects is not where it looks, which is why a generator that performs writes is usually a mistake.
The related tools are worth knowing as a set: enumerate when you need the index, zip to walk two sequences together, itertools for the standard lazy patterns such as chain, islice and groupby, and any and all for short-circuiting checks. Most loops that build a list, filter it and then reduce it are one line of these, and the one line is both faster and easier to check.
Laziness is how Python handles data larger than memory without changing how the code reads. The trade is that work happens where it is consumed rather than where it is written, so errors and side effects appear somewhere other than the line that seems to cause them.
A five gigabyte file must be processed on a machine with one gigabyte of memory. What shape of code fits?
The GIL and how to work with it
advancedCPython has a global interpreter lock: one thread executes Python bytecode at a time, per interpreter. Threads are therefore real operating system threads that take turns, which means threading gives you concurrency and not parallelism for anything that computes. Four threads doing arithmetic on four cores run at roughly the speed of one.
The lock is released around blocking operations, which is why threading is still the right tool for IO. A thread waiting on a socket, a file or a database has released the lock, so other threads run. For a program that spends its time waiting, threads work exactly as you would hope, and asyncio does the same job with less memory per task and explicit switch points.
For CPU-bound work the answer is more than one interpreter: multiprocessing, or a process pool, so each process has its own lock and its own memory. The cost is that arguments and results are pickled and copied between processes, so the work per task has to be large enough to be worth the transfer. The other answer is to leave Python for the hot loop, which is what NumPy, Polars and every serious numeric library already do by releasing the lock and running C.
This is changing. PEP 703 added an experimental free-threaded build in Python 3.13, which removes the lock and allows genuine multi-core threading, at the cost of some single-threaded performance and a long tail of C extensions that need to be made safe. It is worth knowing about and not yet worth assuming, which means the practical advice stands: threads for IO, processes for CPU, and a library that drops into C for the numerical work.
The GIL is the reason a Python program that looks parallel is not, and the reason the standard advice is threads for waiting and processes for computing. Knowing which of the two your workload is decides the concurrency model before any code is written.
NumPy and its descendants release the interpreter lock while running C, which is why numeric Python is fast without being parallel at the Python level. Python 3.13 shipped an experimental free-threaded build under PEP 703, which removes the lock entirely and is not yet the default.
A CPU-bound Python program is given four threads and does not get faster. Why?
Type hints, and what they do not do
advancedPython's type hints are annotations that the interpreter records and does not enforce. Passing a string where an int is annotated runs perfectly happily. The value comes entirely from tools: a checker such as mypy or pyright reads them and tells you before you run anything, and an editor uses them for completion and navigation.
That makes them a documentation and tooling feature with teeth, and the returns are highest exactly where dynamic typing hurts most: function boundaries in a large codebase, data passed between modules, and anything another team will call. Annotating internal one-line helpers has a much worse ratio.
The pieces worth learning are Optional for the value that might be None, which is the most common runtime error in any language that has null, union types for genuine alternatives, Protocol for structural typing so a parameter can require behaviour rather than inheritance, and generics for containers whose contents matter. TypedDict is the pragmatic answer for the dictionary shapes that real programs pass around.
The trap is believing the annotations at run time. Data arriving from a network, a queue or a file is whatever it is, and an annotation saying it is a User does not make it one. Validation at the boundary, with a library such as Pydantic or a hand-written check, is what turns a hint into a guarantee, and the combination of the two is what makes a typed Python codebase actually safer rather than merely better documented.
Hints move a class of errors from run time to check time, but only for code that a checker sees. At the edges, where data arrives from outside the program, they describe an intention rather than a fact, and the difference has to be closed by validation.
A function annotated to take an int is called with a string. What happens at run time?