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.
What is the difference between the is operator and equality in Python?
Evidence · 1 source
- 01Python language reference: the data model
That operators and built-in functions are defined in terms of special methods, so implementing the protocol methods is the whole contract for a user-defined type.
Answers are generated and can be wrong. The topic above is the reviewed version.
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.
Why is a tuple containing a list unusable as a dictionary key?
Answers are generated and can be wrong. The topic above is the reviewed version.
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.
A generator is consumed twice and the second pass is empty. Why?
Answers are generated and can be wrong. The topic above is the reviewed version.
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, and it is worth being precise about how far. PEP 703 added a free-threaded build in Python 3.13, where the interpreter lock is genuinely absent and threads run on separate cores. It shipped as experimental; PEP 779 moved it to officially supported in Python 3.14. It is still not the default build, single-threaded code pays a small penalty for it, and a long tail of C extensions has to be made thread-safe before it is boring. So the practical advice stands for now, threads for waiting and processes for computing, while the reason behind it is being removed underneath.
Why does threading still help a program waiting on a database?
Evidence · 2 sources
- 01PEP 703: making the global interpreter lock optional in CPython
That a free-threaded build removing the interpreter lock was accepted and shipped as experimental in Python 3.13.
- 02PEP 779: criteria for supported status for free-threaded Python
That free-threaded Python moved to officially supported in Python 3.14, while remaining a separate build rather than the default.
Answers are generated and can be wrong. The topic above is the reviewed version.
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.
Why should external data start life as unknown rather than as a declared type?
Evidence · 1 source
- 01Python documentation: typing
That annotations are recorded and not enforced at run time, so their value comes entirely from checkers and editors rather than from the interpreter.
Answers are generated and can be wrong. The topic above is the reviewed version.