Filter concepts by levelShowing all levels.

Python · Fundamentals

Core language

Names, the objects behind them, and the containers you reach for every day

  • Names, not boxes= binds, it never copies
  • Immutable by defaultint, str, bytes, tuple
  • O(1) membershipset and dict, not list
  • Half-open slicesthe stop index is excluded

1.Variables, objects, names, and references

A name is a label you attach to an object. Assignment attaches the label; it never copies the object. Two names can label one object, so a change made through one name is visible through the other.

Key features

  • = binds a name to an object; it never copies
  • id() reports which object a name is bound to
  • a is b compares identity, a == b compares value
  • Two names on one list share every mutation
  • Rebinding moves one name; mutating changes the object

Mutable, and usable as a key

Mutable, and usable as a key
TypeMutableHashable
int, floatnoyes
str, bytesnoyes
tuplenoonly if every item is
listyesno
setyesno
dictyesno
Two names, one object

One list, two labels — a change through either is visible through both

  • Two name labels sit on the left, one above the other: retry_budget and backup_budget.
  • An arrow runs from each label to a single box on the right holding the list [1, 2, 4].
  • There is one object and two names, so id(retry_budget) and id(backup_budget) are equal.
  • Appending through either name changes that one shared list, so the other name sees it too.

Command

python
retry_budget = [1, 2, 4]       # bind a name to a list object
backup_budget = retry_budget   # second name for the SAME object
backup_budget is retry_budget  # True — one object, two names
backup_budget = [1]            # rebind: moves only backup_budget

Assignment binds a name; it never copies an object. Two names on one list means an append through either is visible through both.

2.Strings

A str is an immutable sequence of Unicode code points. Nothing edits one in place: every method that looks like a change returns a new string and leaves the original alone.

Key features

  • Immutable — every method returns a new str, none edit in place
  • Indexing gives a 1-character str; there is no char type
  • f-strings are the default formatting: f"{user_id} in {region}"
  • strip, replace, split and join return; they never mutate
  • "".join(parts) is one pass; += in a loop is quadratic

The str methods worth knowing

The str methods worth knowing
CallGives back
" a ".strip()'a'
"py".upper()'PY'
"PY".lower()'py'
"a,b".split(",")['a', 'b']
"-".join(["a", "b"])'a-b'
"ab".replace("a", "X")'Xb'
"report.py".startswith("rep")True
"report.py".endswith(".py")True
"a,b,c".count(",")2
"a,b".find("b")2
"a,b".find("z")-1 — no exception, unlike .index()
"tmp_log".removeprefix("tmp_")'log'
"log.txt".removesuffix(".txt")'log'
"7".zfill(3)'007'
"a\nb".splitlines()['a', 'b']
"k=v".partition("=")('k', '=', 'v')
"a".center(5, "-")'--a--'
"user_42".isidentifier()True
"42".isdigit()True
"Straße".casefold()'strasse' — for caseless comparison
"py".encode("utf-8")b'py' — a bytes, not a str

Operators, and the older formatting

Operators, and the older formatting
WrittenResult
"py" + "thon"'python' — both sides must be str
"-" * 5'-----'
"th" in "python"True — substring test, not a character test
len("python")6 — characters, not bytes
"python"[-1]'n' — counts from the right
"a" < "b"True — compares by code point
"Z" < "a"True — every uppercase letter sorts before lowercase
r"a\nb"a backslash and an n, not a newline
"%s=%d" % ("a", 1)'a=1' — the pre-f-string style, still in old code
A string, and its replacement

Every method that "changes" a string returns a second one and leaves the first.

  • The string "async" is drawn as five boxes in a row, one box per code point, indexed 0 to 4 underneath.
  • The box at index 2 is highlighted: tag[2] is the one-character string "y". Python has no separate character type, and no box can be rewritten.
  • An arrow leads down to a second, separate row of five boxes holding A, S, Y, N and C — the result of tag.upper().
  • That second row is a new string object. The first row is unchanged, because tag still names it.

Command

python
tag = "  Async  "
parts = ["python", "async"]

tag.strip()           # 'Async' — a new str; tag is unchanged
tag[2]                # 'A' — a 1-character str
f"tag={tag.strip()}"  # 'tag=Async' — the default way to format
", ".join(parts)      # 'python, async' — one pass, one allocation

No string method edits in place — every one returns a new str. Build with "".join(parts), not += in a loop.

3.f-strings

An f-string embeds expressions directly inside a string literal: f"{price}" evaluates price and inserts it. A colon after the expression switches on a small formatting language for width, precision and alignment.

Key features

  • f"{expr}" evaluates expr and inserts its str()
  • f"{expr!r}" inserts repr(expr) instead — quotes included
  • Everything after the colon is the format spec: width, precision, alignment
  • f"{expr=}" prints "expr=value", built for debugging
  • No format spec means no rounding — a float prints every digit it has

Format spec, by what you are printing

Format spec, by what you are printing
WrittenPrints
f"{name}"async
f"{name!r}"'async' — repr, quotes included
f"{ratio=}"ratio=0.0721 — name and value, for debugging
f"{amount:.2f}"1234.57
f"{amount:,.2f}"1,234.57
f"{ratio:.1%}"7.2%
f"{name:>10}" async — right-aligned in 10
f"{name:<10}|"async |
f"{name:^11}|" async |
f"{n:03}"042
f"{n:#x}"0x2a
f"{n:_>6}"____42 — pad with any character
The four parts of a replacement field

f"{total!r:>10}"

f"

The f prefix — Marks the string as an f-string. Without it, {total} is literal text.

{total

The expression — Any Python expression — a name, an attribute, a call, arithmetic.

!r

The conversion — Optional. !r calls repr(), !s calls str(), !a calls ascii().

:>10

The format spec — Everything after the colon: alignment, width, precision, type.

}"

Close — Ends the replacement field, then the string.

  • Whole: f"{total!r:>10}"
  • f" — The f prefix: Marks the string as an f-string. Without it, {total} is literal text.
  • {total — The expression: Any Python expression — a name, an attribute, a call, arithmetic.
  • !r — The conversion: Optional. !r calls repr(), !s calls str(), !a calls ascii().
  • :>10 — The format spec: Everything after the colon: alignment, width, precision, type.
  • }" — Close: Ends the replacement field, then the string.

Command

python
total = 59.699999999999996

f"{total}"        # '59.699999999999996' — no spec, no rounding
f"{total:.2f}"    # '59.70' — 2 digits after the point
f"{total=}"       # 'total=59.699999999999996' — name and value, for debugging

f"{value}" is str(value) with no rounding. Add :.2f, or the noise a float actually carries prints in full.

4.Lists

A list is an ordered, changeable row of items. Positions start at 0, the row holds anything you put in it — even a mix of types — and it grows when you append.

Key features

  • Ordered and mutable — slot 0 is the first item
  • Holds anything, including a mix of types
  • append() is amortised O(1); insert(0, x) is O(n)
  • Membership (x in items) walks the row — O(n)
  • sort() mutates and returns None; sorted() returns a new list
  • A list default argument is created once, at def time

list methods, and what each hands back

list methods, and what each hands back
CallResultMutates?
items.append(4)Noneyes — adds to the end
items.extend([3, 4])Noneyes — adds each item
items.insert(0, 9)Noneyes — O(n), shifts everything
items.remove(2)Noneyes — first match, else ValueError
items.pop()the last itemyes
items.pop(0)the first itemyes — O(n)
items.reverse()Noneyes — in place
items.sort()Noneyes — in place
sorted(items)a new sorted listno — leaves items alone
items.index(2)the position, else ValueErrorno
items.count(1)how many times it occursno
items.copy()a new, shallow copyno
A list is a numbered row of slots

Positions are numbered from both ends; the row grows on the right.

  • Four numbered slots sit in a row. Slot 0 holds the string SKU-8841, slot 1 holds SKU-2210, slot 2 holds SKU-9007 and slot 3 holds SKU-4413.
  • The same slots are numbered again underneath from the right: -4, -3, -2 and -1, so index -1 is always the last item.
  • A dashed slot sits off the right-hand end labelled append(). That is where a new item lands, and the cost does not grow with the row.
  • A line under the four filled slots marks the ground that a membership test or insert(0, x) has to walk, which is why both cost O(n).

Command

python
order_skus = ["SKU-8841", "SKU-2210"]  # ordered, mutable
order_skus.append("SKU-9007")          # grow at the right-hand end
order_skus[0] = "SKU-0001"             # replace one slot in place
order_skus[-1]                         # 'SKU-9007' — last slot

list.sort() rearranges the list and returns None. sorted(items) leaves it alone and hands you a new list.

5.Dictionaries

A dictionary maps keys to values. You look up a value by its key, and Python reaches it by hashing that key instead of scanning the entries, so lookups stay fast as the dictionary grows.

Key features

  • Keys must be hashable — a list or dict cannot be one
  • Insertion order is guaranteed since Python 3.7
  • d[key] raises KeyError, d.get(key) returns None
  • .keys(), .values(), .items() are live views
  • Resizing a dict mid-iteration raises RuntimeError
  • Average O(1) lookup — hashed, never scanned

dict methods, and what each hands back

dict methods, and what each hands back
CallResult
settings["region"]the value, else KeyError
settings.get("region")the value, else None
settings.get("region", "eu")the value, else the fallback
settings.setdefault("k", 5)the existing value, else inserts 5
settings.pop("k")the value, and removes it
settings.update({"b": 2})None — merges in place
defaults | overridesa new merged dict; the right side wins
settings.keys()a live view of the keys
settings.values()a live view of the values
settings.items()a live view of (key, value) pairs
list(settings.items())[('a', 1)] — a real list of tuples
settings.copy()a new, shallow copy
A dict is a table of key/value rows

One hash lands on the row — the rows above it are never read

  • A two-column table stands for one dictionary: the left column holds keys, the right holds their values.
  • Three rows, in the order they were inserted: "us-east-1" maps to 412, "eu-west-1" maps to 87, and "ap-south-1" maps to 5.
  • An arrow on the left, labelled d["eu-west-1"], hashes that key and lands directly on the middle row instead of walking the row above it.
  • Notes under the table record that insertion order is kept from Python 3.7 onward, and that a key the dictionary does not hold raises KeyError.

Command

python
settings = {"host": "db.internal", "port": 5432}

settings["timeout"] = 30                # add, or overwrite what is there
settings["port"]                        # 5432 — KeyError if the key is absent
settings.get("retry_budget", 3)         # 3 — a default instead of an error
"host" in settings                      # True — membership tests the keys

d[key] raises KeyError when the key is absent. Use d.get(key, default) whenever the key might not be there.

6.Slicing

seq[start:stop] builds a new sequence holding the items from start up to but not including stop. The item beginning at the stop position is the first one left out.

Key features

  • Half-open: start is included, stop is left out
  • Omitted start is 0; omitted stop is the end
  • Negative positions count back from the right
  • Out-of-range bounds clamp to the sequence, never raise IndexError
  • seq[:] copies the sequence, not the items inside it

What each slice hands back

What each slice hands back
SliceResult
items[2:5]index 2, 3 and 4 — never 5
items[:3]the first three
items[-2:]the last two
items[::2]every second item
items[::-1]a reversed copy
Positions sit between the items

seq[1:4] takes what lies between position 1 and position 4 — three items

  • Five boxed records in a row, labelled 101 to 105.
  • Six numbered positions, 0 to 5, sit on the boundaries between and around the boxes — not on the boxes themselves.
  • Beneath the same boundaries, the negative names -5, -4, -3, -2 and -1 count back from the right; the final boundary has no negative name, so it is labelled "end".
  • A bracket spans position 1 to position 4 and highlights the three boxes 102, 103 and 104. Box 105, which starts at position 4, is outside the bracket.

Command

python
records[1:4]   # positions 1, 2, 3 — position 4 is left out
records[:3]    # from the start
records[3:]    # to the end
records[-2:]   # the last two
records[:]     # a shallow copy

stop is never included. For bounds inside the sequence len(seq[a:b]) is b - a, and seq[:i] + seq[i:] rebuilds the original for any i.

7.Unpacking

Assignment can bind several names at once. Put a comma-separated list of names on the left, and Python takes the iterable on the right apart position by position — one name per position.

Key features

  • One name per position — the counts must match exactly
  • A mismatch raises ValueError and binds nothing at all
  • The right side is built first, so a, b = b, a swaps
  • Targets bind left to right, once the right side exists
  • for key, value in mapping.items() unpacks each pair
  • Unpacking a dict yields its keys, not its key/value pairs

The shapes unpacking takes

The shapes unpacking takes
WrittenUse it for
host, port = endpointa fixed-shape tuple, positions named
a, (b, c) = (1, (2, 3))nested — mirror the shape of the data
for k, v in mapping.items()looping over key/value pairs
_, port = endpointdiscarding a position you do not need
host, port = get_endpoint()a function's return value, unpacked directly
What one unpacking assignment does, in order

Right side first

("db.internal", 5432) is built

Counts must match

2 values, 2 names — otherwise ValueError

Position by position

slot 0 → host, slot 1 → port

Both names bound

left to right, in one statement

  1. Right side first — ("db.internal", 5432) is built
  2. Counts must match — 2 values, 2 names — otherwise ValueError
  3. Position by position — slot 0 → host, slot 1 → port
  4. Both names bound — left to right, in one statement

Command

python
host, port = ("db.internal", 5432)             # two names, two positions
host, port = parse_endpoint(raw_endpoint)      # a function's two return values
retry_budget, timeout = timeout, retry_budget  # swap — the right side is built first

for resource, amount in limits.items():        # one key/value pair per turn
    print(resource, amount)

The right side is built in full before any name is bound, which is why a, b = b, a swaps. The counts must match exactly, or ValueError.

8.Comprehensions

A comprehension is one expression that builds a whole container: [expression for item in iterable if condition]. It walks the iterable once, keeps what the condition allows, and collects what the expression produces.

Key features

  • Four parts: output expression, binding, iterable, optional filter
  • An expression, so it can go anywhere a value can go
  • Own scope — the loop name raises NameError outside the brackets
  • Nested for clauses read in the same order as nested for statements
  • Brackets pick the container: [] list, {} set, {k: v} dict, () generator
  • Built for a result — a discarded list of Nones means write a for loop

Which bracket builds which container

Which bracket builds which container
WrittenYou get
[x for x in items]a list, built now
{x for x in items}a set, duplicates dropped
{k: v for k, v in pairs}a dict, last key wins
(x for x in items)a generator, nothing built yet
The four parts, in the order they run

iterable

in line_items — the source

binding

for item — one name per pass

filter

if item["paid"] — optional

expression

item["sku"] — the element built

  1. iterable — in line_items — the source
  2. binding — for item — one name per pass
  3. filter — if item["paid"] — optional
  4. expression — item["sku"] — the element built

Command

python
[expression for item in iterable if condition]  # list
{expression for item in iterable}               # set
{key: value for item in iterable}               # dict
(expression for item in iterable)               # generator — lazy, not a tuple

[cell for row in grid for cell in row]          # nested: the order of two for statements

Build with a comprehension; act with a for loop. The loop name lives inside the brackets only, so reading it afterwards raises NameError.

9.Generator expressions

A generator expression is a comprehension written in round brackets. It builds no container: each item is computed at the moment something asks for it, so memory stays flat however long the source is.

Key features

  • Round brackets, not square: (line.total for line in line_items)
  • Returns a generator object — printing one shows <generator object ...>
  • One-shot: once consumed it stays empty, and it never rewinds
  • Flat memory — 208 bytes here against 8,448,728 for the list
  • Sole argument to a call needs no extra brackets: sum(n for n in rows)
  • No len(), no indexing, no slicing

What accepts a generator directly

What accepts a generator directly
CallWorks?
sum(x for x in rows)yes — pulls one item at a time
any(x for x in rows)yes — stops at the first True
all(x for x in rows)yes — stops at the first False
sorted(x for x in rows)yes — but this DOES read it all into memory
len(gen)no — TypeError: object of type 'generator' has no len()
gen[0]no — TypeError: 'generator' object is not subscriptable
Items arrive one at a time, and only once

Source

line_items — any length

Generator

holds the loop, not the items

sum()

pulls one item at a time

Exhausted

a second sum() gives 0

  1. Source — line_items — any length
  2. Generator — holds the loop, not the items
  3. sum() — pulls one item at a time
  4. Exhausted — a second sum() gives 0

Command

python
line_totals = (line.total for line in line_items)  # no total computed yet
next(line_totals)                                 # now one item is computed

sum(line.total for line in line_items)       # sole argument — no extra brackets
sum((line.total for line in line_items), 0)  # not sole — bracket the generator

A generator is one-shot. After sum() has walked it, it is empty: a second sum() returns 0 and max() raises ValueError. Use list() to keep the items.

10.Truthiness

Every object has a truth value, so if value: works on anything, not only on a bool. False, None, zero and every empty container are falsy. Everything else is truthy, including the string "False".

Key features

  • Falsy: False, None, 0, 0.0, "", b"", (), [], {}, set(), range(0)
  • Everything else is truthy — including "False", " " and [0]
  • bool() asks __bool__ first, then __len__, then answers truthy
  • and / or return an operand, not a bool: 0 or 3 evaluates to 3
  • A class defining neither method makes every instance truthy

Cases that surprise people

Cases that surprise people
WrittenResult
bool(float("nan"))True — NaN is not zero, so it is truthy
bool(0.0j)False — a zero complex number is falsy too
bool(1 + 0j)True — any non-zero complex number is
class X: def __len__(self): return -1bool(X()) raises ValueError
How bool() answers — the first rung that applies wins

__bool__

Defined? Python calls it and takes its word. It must return True or False.

__len__

No __bool__? Length decides: 0 is falsy, any other length is truthy.

Truthy

Neither method? The object is truthy. That is the default for every class.

  1. __bool__ — Defined? Python calls it and takes its word. It must return True or False.
  2. __len__ — No __bool__? Length decides: 0 is falsy, any other length is truthy.
  3. Truthy — Neither method? The object is truthy. That is the default for every class.

Command

python
if line_items:                          # truthy: the list holds an item
    send_invoice(line_items)

display_name = raw_name or "anonymous"  # or returns an operand, not a bool
print(bool(""), bool("False"))          # False True

Empty is not missing. if not retry_budget: also fires on 0 and "", so test if retry_budget is None: when you mean absent.

11.is vs ==

is asks whether two names point at the exact same object; == asks whether they count as equal, via __eq__. Two separately built objects can hold an equal value and still fail an is check — that gap is where most is/== bugs live.

Key features

  • is compares identity via id(); == calls __eq__ to compare value
  • CPython caches ints -5..256 and some strings, so is can agree with == by coincidence
  • None, True and False are singletons — always test them with is
  • float("nan") == float("nan") is False; use math.isnan() instead
  • x is 404 warns SyntaxWarning: "is" with 'int' literal. Did you mean "=="?

is vs == across common comparisons

is vs == across common comparisons
Comparedis==
5 and 5 (small-int cache)TrueTrue
a view count parsed twice from JSONFalseTrue
two f-strings built from the same valueFalseTrue
float("nan") and float("nan")FalseFalse
None and NoneTrueTrue
two custom objects with equal fieldsFalseTrue, if __eq__ compares fields
Two different questions

is — identity

  • Asks: the exact same object?
  • True only when id(a) == id(b)
  • A class cannot override it
  • The only correct test for None, True, False

== — equality

  • Asks: does this object call itself equal?
  • Calls a.__eq__(b) and trusts the answer
  • A class can override __eq__ to lie
  • The right test for comparing values
  • is — identity
    • Asks: the exact same object?
    • True only when id(a) == id(b)
    • A class cannot override it
    • The only correct test for None, True, False
  • == — equality
    • Asks: does this object call itself equal?
    • Calls a.__eq__(b) and trusts the answer
    • A class can override __eq__ to lie
    • The right test for comparing values

Command

python
cached_row is None          # identity — correct: None has exactly one instance
first_name == second_name   # equality — correct: compares the characters
status_code == 200          # equality — never write "is" against a literal
cached_row is live_row      # identity — are these two names the same object?

is asks "same object?", == asks "same value?". Reserve is for None, True, False and identity checks — use == for anything parsed, computed, or built twice.

12.Mutable vs immutable objects

A mutable object can change while staying the same object — id() is unchanged, contents are not. An immutable one never changes: every edit builds a new object. Passing one into a function decides whether it can edit what the caller holds.

Key features

  • int, float, str, bytes, tuple, frozenset — immutable; every "change" builds a new object
  • list, dict, set, bytearray — mutable; a method can change them in place
  • A mutable argument passed to a function can be edited by it, visible to the caller
  • x += y calls x.__iadd__(y) and mutates in place when x defines it; otherwise it rebinds x
  • A tuple never changes itself, but it can still "change" if it holds a mutable item

In place, or a new object?

In place, or a new object?
Typex += yA method call
listmutates in place — id(x) unchanged.append(), .sort() mutate in place too
strrebinds x to a new objectevery str method returns a new string
tuplerebinds x to a new objectno mutating method exists at all
dictTypeError — dict has no +.update(), .pop() mutate in place
int, floatrebinds x to a new objectno mutating methods — numbers have none
What += actually does

Mutable — list

  • append(), sort(), += all edit the object in place
  • id() before and after is the same
  • Every alias, and every caller, sees the change

Immutable — str

  • Every "change" builds a new object
  • id() before and after differs
  • Other names holding the original see nothing
  • Mutable — list
    • append(), sort(), += all edit the object in place
    • id() before and after is the same
    • Every alias, and every caller, sees the change
  • Immutable — str
    • Every "change" builds a new object
    • id() before and after differs
    • Other names holding the original see nothing

Command

python
values = [1, 2]
values.append(3)          # mutates in place — values is now [1, 2, 3]

label = "v1"
label += ".2"              # rebinds — a new string; the original "v1" is untouched

def add_item(items, item):
    items.append(item)     # visible to the caller — items is the caller's own list

A function receives the same object the caller holds, never a copy. Mutating it is visible to the caller; += on an immutable one only ever rebinds a local name.

Remember

  • b = a gives you a second name for one object, not a second object.
  • A list default argument is created once, at def time, and shared by every call.
  • seq[a:b] has b - a items, and seq[:i] + seq[i:] is always seq.
  • Test for absence with "is None". A class can redefine == and lie.

Pro tip

  • Reach for a set the moment you write "x in" against a long list.
  • Use d.get(key, default) unless a missing key really is a bug worth raising.
  • seq[::-1] reverses a copy; list.reverse() reverses in place and returns None.
  • A generator expression is one-shot. Consume it twice and the second pass sees nothing.

Next up

  • Keyword, default, keyword-only and positional-only arguments
  • *args, **kwargs, and first-class functions
  • Closures, lambdas, scope and the LEGB rule

Sources

  • Data model — docs.python.org/3/reference/datamodel
  • Built-in types — docs.python.org/3/library/stdtypes
  • Every example executed on Python 3.14.3 · checked 2026-08-20

Spotted a mistake on this page? Tell us.