Python's Readability Is a Design Constraint, Not a Slogan
Python is usually introduced as "the easy language", and that description does it a quiet disservice. It is easy to start with, which is not the same thing. What Python actually is, is a language where a specific trade was made deliberately and repeatedly: when forced to choose between making the code easy to write and making it easy to read, Python chooses reading.
That choice explains almost everything about the language, including the parts people complain about. The significant whitespace that makes newcomers uneasy. The absence of a ternary operator for years, then one that reads as a sentence. The refusal to add assignment inside expressions until a syntax arrived that could not be confused with equality. Even the global interpreter lock, which trades a kind of performance for a much simpler mental model of what memory is doing.
Understanding Python as a set of deliberate trades, rather than as a collection of conveniences, is what turns someone who can write Python into someone who writes good Python.
Indentation is syntax, and that was not an accident
Every other mainstream language uses braces for blocks and indentation for human readers, which means the two can disagree. A block can be indented one way and nested another, and the compiler follows the braces while the reader follows the whitespace. Entire security vulnerabilities have come from precisely that gap.
Python removed the possibility by making them the same thing. There is no way for the structure you see to differ from the structure that runs.
The cost is real and worth naming: whitespace is invisible, so a mixed tab and space file can look correct and fail. Python 3 made that an error rather than a silent mismatch, and the convention — four spaces, never tabs — is near-universal. Configure your editor once and the problem disappears permanently.
Everything is an object, including the things that do not look like it
In Python, integers are objects. Functions are objects. Classes are objects. Modules are objects. There is no separate category of primitive values with different rules.
This has practical consequences you use daily even without noticing. A function can be passed to another function, stored in a dictionary, or returned. A class can be assigned to a variable. None is a singleton object, which is why is None is the correct comparison rather than == None — you are asking about identity, and there is exactly one None.
It also explains a behaviour that confuses beginners: a = b for a list does not copy anything. Both names now refer to the same object, and mutating through either is visible through both. Python has names bound to objects, not variables containing values, and once that model is in place the behaviour stops being surprising.
The mutable default argument
This is the most famous Python trap, and it follows directly from the previous section.
def add_item(item, basket=[]):
basket.append(item)
return basket
Call it twice and the second call returns both items. The default value is evaluated once, when the function is defined, not on each call — so every invocation shares one list.
The fix is the idiom you will see everywhere:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
It is worth understanding rather than memorising, because the same principle applies to class attributes: a mutable class attribute is shared by every instance, which produces the same bug one level up and is considerably harder to spot.
Data structures, and picking the right one
Four built-in types cover most work, and choosing correctly is most of what makes Python code fast enough.
A list is ordered and mutable. Appending is cheap; inserting at the front is not, because everything shifts. Checking membership with in scans the whole list.
A tuple is ordered and immutable. Use it for a fixed group of related values — a coordinate, a record — and for dictionary keys, which lists cannot be precisely because they can change.
A dictionary maps keys to values with constant-time lookup. Since Python 3.7 it preserves insertion order, which is now a language guarantee rather than an implementation detail.
A set holds unique items with constant-time membership. Replacing if x in big_list with if x in big_set is the single most common fix for Python code that is unexpectedly slow, and it is a one-word change.
The habit worth building: when you write a loop that searches a list inside another loop, stop and ask whether a set or a dict would make it a lookup instead.
Comprehensions, and when they stop helping
A comprehension builds a collection in one expression.
squares = [n * n for n in numbers if n > 0]
by_id = {user.id: user for user in users}
They are idiomatic, they are faster than the equivalent loop with append, and they read as a description of the result rather than a procedure for building it.
They also have a limit, and over-running it is a common way to write Python that only the author can read. The moment a comprehension contains two for clauses and a conditional, or wraps to a third line, a plain loop is clearer. The goal was never brevity — it was readability, and a dense one-liner is not readable just because it is short.
Generators and the difference laziness makes
A function with yield returns a generator: a sequence produced one item at a time, on demand.
def read_lines(path):
with open(path) as f:
for line in f:
yield line.strip()
Nothing is read until you iterate, and only one line is in memory at a time. The list version of this function on a two-gigabyte file needs two gigabytes of memory; the generator needs one line.
This is why range in Python 3 produces values lazily rather than building a list, and why most iteration tools in the standard library return iterators. Where you have a pipeline of transformations over a large sequence, generators let the whole pipeline run in constant memory.
The trade: a generator can only be consumed once, and it has no length. If you need to iterate twice or index into it, you need a list, and converting is explicit precisely so you notice the memory decision you are making.
Context managers, and resources that close themselves
with open('data.csv') as f:
process(f)
The file closes when the block ends, including when the block raises. This is the same guarantee as try/finally, with none of the ceremony and no way to forget it.
The underlying protocol is simple enough to implement yourself — enter and exit, or the contextmanager decorator over a generator — and it applies to far more than files. Database transactions, locks, temporary directories, timing blocks, and temporarily changed settings all fit the same shape: acquire something, guarantee release.
A good rule: whenever you write code that acquires a resource and must release it, you are writing a context manager, whether or not you use the syntax.
Exceptions are for control flow here, and that is normal
Python's culture differs from languages where exceptions are strictly exceptional. The idiom is "easier to ask forgiveness than permission" — attempt the operation and handle failure, rather than checking first.
try:
value = config['timeout']
except KeyError:
value = 30
This is preferred over checking if 'timeout' in config for a reason beyond style: the check-then-act version has a gap between the check and the use, and in concurrent code the state can change in between. Attempting the operation has no gap.
The rules that keep this from becoming chaos: catch the narrowest exception that fits, never write a bare except: (it catches keyboard interrupts and system exits too), and never silently swallow an exception without either handling it or re-raising. An empty except: pass is how a program loses errors it needed to report.
The standard library is unusually large, and worth browsing
Python's standard library is one of its genuine advantages, and a surprising amount of code gets written to reproduce something already in it.
collections has Counter for tallying, defaultdict for grouping without key checks, and deque for a queue with cheap operations at both ends. itertools has combinatorics and iterator plumbing. pathlib replaces string path manipulation with objects that know what a path is. dataclasses removes the boilerplate from classes that mostly hold data. json, csv, sqlite3, datetime, re, subprocess — all present, all documented.
Spending an hour reading the module index is one of the higher-return hours available to a Python programmer, because you cannot reach for a tool whose existence you do not know about.
Type hints: optional, erased, and still useful
Python added optional type annotations, and they behave much as you would expect from a language that values readability: they are documentation that tools can check.
def apply_discount(order: Order, percent: float) -> Order:
...
The interpreter does not enforce these at run time. They are checked by a separate tool such as mypy or pyright, and in an editor they power completion and inline errors.
They earn their place on function signatures in code that other people call, and on anything handling data whose shape is not obvious. They earn it less on short scripts. The honest framing is the same as TypeScript's: annotations do not make the program safe, they make its assumptions explicit so that a tool can notice when two parts disagree.
The global interpreter lock, explained once
Python's reference implementation has a lock that allows only one thread to execute Python bytecode at a time. This means threads do not give you parallel CPU work.
What they do give you is concurrency for waiting. If your threads are blocked on network requests or disk, the lock is released while they wait, and threading helps a great deal. For CPU-bound work you need multiprocessing, which uses separate processes with separate interpreters, or a library that releases the lock internally — which is exactly what NumPy and similar numerical libraries do.
The practical rule: I/O-bound work, use threads or asyncio. CPU-bound work, use processes. Knowing which kind you have is the whole decision, and measuring is how you find out.
Virtual environments are not optional
Python installs packages globally by default, and two projects needing different versions of the same library is a conflict with no good resolution. A virtual environment gives each project its own isolated set.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
This is three lines and it prevents a category of problem that is genuinely painful to untangle later. Pin your dependencies to versions, commit the file that records them, and treat an environment as something you can delete and rebuild rather than something you maintain.
Modules, packages, and the import that bites
A Python file is a module; a directory of them is a package. import runs the
module once and caches it, so importing the same module from five places costs
nothing after the first.
The trap is that the interpreter searches a specific list of directories, and
the first entry is the directory of the script you ran. Name a file
random.py in your project and every import random anywhere in that project
finds yours instead of the standard library's, producing an error message that
points nowhere useful. The same applies to json.py, string.py, email.py
and every other module name you might innocently reach for.
The second trap is the circular import: module A imports B, which imports A.
Python handles this partially — the second import gets a half-built module —
and the symptom is an attribute that exists in the file but not at import
time. The fix is almost never a clever import trick; it is that two modules
know too much about each other and something belongs in a third.
Testing is cheap enough that there is no excuse
Python ships unittest, and the ecosystem overwhelmingly prefers pytest,
which reduces a test to a function with an assertion.
def test_discount_applies():
assert apply_discount(100, 20) == 80
No class, no special assertion methods, no setup ceremony. The barrier is low
enough that the usual argument against testing — that it is slower than just
running the code — stops being true for anything you would otherwise check by
hand more than twice.
This matters more in Python than in a compiled language, and for the same
reason it matters in Ruby: there is no compiler to catch a misspelled
attribute or a function called with the wrong number of arguments. In a
dynamic language the test suite is doing a job that elsewhere is done before
the program ever runs, and skipping it is not a neutral choice.
What to practise
- Rewrite a loop that uses
inon a list as a set lookup, on data large enough to time. The result makes the cost of the wrong data structure concrete. - Convert a function that builds and returns a list into a generator, and run it over a file too large to fit in memory.
- Write a context manager for something that is not a file — a timer, a temporary directory, a database transaction.
- Trigger the mutable default argument bug deliberately, then fix it. Understanding why is worth more than remembering the rule.
- Read the
collectionsanditertoolsdocumentation once, start to finish. You will not remember it all, but you will remember that something exists, which is what matters. Python rewards restraint. The language will let you write something clever, and the culture will quietly ask you not to. Almost every convention — explicit over implicit, flat over nested, one obvious way to do it — is in service of the same goal: that the person reading this in a year, who may well be you, should not have to work it out.