Python is dynamically typed and strongly typed . These two properties are frequently Confused, so it is worth being precise about what they mean.
Dynamic typing: Variables do not carry type annotations that the interpreter enforces at assignment. A name is bound to an object, and that object has a type. The same name can be rebound to an object of a completely different type at any time. Type checking happens at runtime, not at compile time.Strong typing: The interpreter does not perform implicit type coercions that could silently lose data. Operations between incompatible types raise TypeError rather than silently converting one operand to match the other.## Dynamic: the name 'x' is rebound to different types
## Strong: these raise TypeError, not silent coercion
" hello " + 42 # TypeError: can only concatenate str (not "int") to str
[ 1 , 2 ] + ( 3 , 4 ) # TypeError: can only concatenate list (not "tuple") to list
Compare this with JavaScript (weakly and dynamically typed), where "5" + 3 silently produces "53"Or C (statically and weakly typed), where implicit conversions between int and float Occur without warning.
Python’s design prioritizes developer velocity and flexibility over static safety. Guido van Rossum designed Python for scripting, prototyping, and teaching — domains where the overhead of Type declarations is a genuine barrier. Dynamic typing enables:
Rapid prototyping without ceremony Highly polymorphic code (duck typing) Metaprogramming and runtime introspection via getattr``setattr``dirAnd type The trade-off is that errors a compiler would catch in statically-typed languages surface at Runtime. Python mitigates this with extensive testing culture and, since Python 3.5, optional static Type checking via type hints (discussed later).
Strong typing prevents entire classes of bugs caused by silent data corruption. When "2" * 3 Produces "222" in Python, that is a deliberate, documented operation on the str type — not an Implicit coercion. The principle is that surprising implicit behavior is more dangerous than Explicit errors .
Subclass of `int`So `True + 1 == 2`. Numeric towers allow `int + float` because the `int` is Promoted to `float`. These are the result of deliberate subtype relationships, not general-purpose Coercion rules.Every object in Python is an instance of object. The built-in types form a hierarchy rooted at objectWith int``strAnd others as direct or indirect subclasses.
classDiagram
direction BT
class object {
+__repr__()
+__str__()
+__eq__()
+__hash__()
+__class__
}
class int {
<<numeric>>
+bit_length()
+to_bytes()
}
class float {
<<numeric>>
+is_integer()
+hex()
}
class complex {
<<numeric>>
+real
+imag
+conjugate()
}
class bool {
+True
+False
}
class str {
+encode()
+format()
+join()
+split()
}
class bytes {
+decode()
}
class bytearray {
+decode()
}
class list {
+append()
+extend()
+sort()
}
class tuple {
+count()
+index()
}
class dict {
+keys()
+values()
+items()
}
class set {
+add()
+discard()
+union()
}
class NoneType {
+None
}
class type {
+__name__
+__bases__
+mro()
}
class type~metaclass~ {
<<metaclass>>
}
type <|-- type~metaclass~
object <|-- type
object <|-- int
object <|-- float
object <|-- complex
object <|-- str
object <|-- bytes
object <|-- bytearray
object <|-- list
object <|-- tuple
object <|-- dict
object <|-- set
object <|-- NoneType
int <|-- bool This hierarchy has immediate consequences. Because bool is a subclass of int isinstance(True, int) returns True. Because type is a subclass of objectAnd object is an Instance of typeThe relationship is circular — this is the metaclass mechanism.
Python provides a rich set of numeric types that differ from most languages in critical ways.
Python integers have no fixed bit width . They are arbitrary-precision (bignum), limited only by Available memory. There is no 32-bit or 64-bit overflow. This is a deliberate design choice that Eliminates an entire class of bugs.
print (x) # a 302-digit number
# Contrast with C where this would overflow
print (sys.maxsize) # 2**63 - 1 on 64-bit systems (platform pointer size)
Why arbitrary precision? In scripting and scientific computing, integer overflow is a frequent Source of silent, catastrophic errors. Python’s target audience (non-systems-programmers) is less Likely to think about bit widths. The cost is performance: big-integer arithmetic is slower than Fixed-width register arithmetic. Python accepts this trade-off because correctness is prioritized Over raw speed.
Internally, CPython represents integers as variable-length arrays of digits (base 2 30 2^{30} 2 30 on 64-bit Systems). Small integers in the range [ − 5 , 256 ] [-5, 256] [ − 5 , 256 ] are pre-allocated and interned — every Reference to 256 points to the same object. This is an optimization that exploits the fact that Small integers appear frequently.
print (a is b) # True (interned)
print (a is b) # False (not interned)
Interning range is a CPython implementation detail, not a language guarantee.Python’s float is a C double — IEEE 754 binary64, providing approximately 15-17 significant Decimal digits and a range of roughly ± 1.8 × 10 308 \pm 1.8 \times 10^{308} ± 1.8 × 1 0 308 .
# The classic floating-point precision issue
0.1 + 0.2 # 0.30000000000000004
math.isclose( 0.1 + 0.2 , 0.3 ) # True
Why not arbitrary-precision decimals? Performance. IEEE 754 arithmetic is implemented in Hardware on every modern CPU. A float addition is a single CPU instruction. Arbitrary-precision Decimals would require software emulation, making all numeric computation orders of magnitude Slower. The pragmatic choice is to use hardware floats by default and provide decimal and fractions modules for cases that require exact arithmetic.
(exact rational arithmetic). Never use `float` for money.Python has first-class support for complex numbers, which is unusual for a general-purpose language. This reflects Python’s roots in scientific computing.
print (z.conjugate()) # (3-4j)
print ( abs (z)) # 5.0 (magnitude)
The decimal module provides arbitrary-precision, base-10 arithmetic with configurable rounding. It Is essential for financial calculations and any domain where binary floating-point representation Errors are unacceptable.
from decimal import Decimal, getcontext
# Exact decimal arithmetic
print (a + b == Decimal( " 0.3 " )) # True
print (Decimal( 1 ) / Decimal( 7 )) # 0.14285714285714285714285714285714285714285714285714
Critical detail: Always construct Decimal from strings, not floats. Decimal(0.1) captures The already-corrupted binary floating-point representation. Decimal("0.1") creates the exact Decimal value.
from decimal import Decimal
print (Decimal( 0.1 )) # Decimal('0.1000000000000000055511151231257827021181583404541015625')
print (Decimal( " 0.1 " )) # Decimal('0.1')
The fractions module stores numbers as exact numerator/denominator pairs. Arithmetic is performed Exactly, with results automatically reduced to lowest terms.
from fractions import Fraction
print (a + b) # Fraction(1, 1)
print (Fraction( 0.1 )) # Fraction(3602879701896397, 36028797018963968)
print (Fraction( " 0.1 " )) # Fraction(1, 10)
As with DecimalConstruct from strings when exactness matters.
Python’s numeric types participate in a coercion hierarchy. When two numeric types interact, the “smaller” type is promoted to the “larger” type:
bool -> int -> float -> complex
This means 1 + 2.5 promotes the int to floatAnd 1 + 2j promotes the int to complex. Decimal and Fraction do not participate in this tower — mixing them with float or complex Raises TypeError.
Python 3 strings are Unicode strings — sequences of Unicode code points. This is a fundamental Difference from Python 2, where the default string type was bytes.
Strings are immutable. Every operation that appears to modify a string actually creates a new one.
s_upper = s.upper() # creates a new string, 's' is unchanged
Why are strings immutable? Several deliberate reasons:
Hashability. Immutable objects can be hashed, which makes them usable as dictionary keys and set members. Mutable strings would require recalculating the hash on every modification, and would allow keys to change after insertion — a source of subtle bugs.Interning and sharing. The interpreter can safely share identical string objects across the program. This reduces memory usage and enables fast identity comparisons.Thread safety. Immutable objects are inherently thread-safe. No lock is needed to read a string that another thread might be “modifying” (it cannot be).C API compatibility. CPython’s internal representation can store C strings directly when all characters are ASCII, avoiding per-character encoding overhead. This optimization is only safe because strings cannot change. The entire string. Use `''.join(iterable)` for linear-time concatenation.# O(n^2) -- creates a new string on each iteration
result += word # copies the entire result each time
# O(n) -- builds the result in a list, joins once
Python source files are UTF-8 by default (PEP 3120). The internal representation of strings in CPython uses a flexible format:
Latin-1 (1 byte per character): Used when all characters are in the range U+0000 to U+00FF.UCS-2 (2 bytes per character): Used when all characters are in the range U+0000 to U+FFFF but some exceed U+00FF.UCS-4 (4 bytes per character): Used when any character exceeds U+FFFF.This is a CPython implementation detail (PEP 393, “Flexible String Representation”) that optimizes The common case of ASCII-only strings to use 1 byte per character, while still supporting the full Unicode range without surrogate pairs.
latin1_str = " cafe \u00e9 "
print (sys.getsizeof(ascii_str)) # 50 bytes (1 byte/char + overhead)
print (sys.getsizeof(cjk_str)) # 82 bytes (2 bytes/char + overhead)
print (sys.getsizeof(emoji_str)) # 76 bytes (4 bytes/char + overhead)
F-strings (PEP 498, Python 3.6+) are the preferred way to embed expressions in strings. They are Evaluated at runtime, not at definition time.
print ( f "2 + 2 = {2 + 2} " )
print ( f "Pi: { pi :.4f } " ) # "Pi: 3.1416"
print ( f " { ' centered ' :^ { width }} " ) # " centered "
# Debugging (Python 3.8+)
print ( f " { x = } " ) # "x = 42"
print ( f " { x * 2 = } " ) # "x * 2 = 84"
from datetime import datetime
print ( f " { now: % Y -% m -% d % H: % M } " ) # "2025-06-04 10:00"
F-strings are faster than %-formatting or str.format() because the bytecode compiler parses the F-string into a sequence of FORMAT_VALUE opcodes at compile time, avoiding the overhead of parsing A format string at runtime.
s.strip() # "Hello, World!"
s.lstrip() # "Hello, World! "
s.rstrip() # " Hello, World!"
" one,two,three " .split( " , " ) # ["one", "two", "three"]
" - " .join([ " a " , " b " , " c " ]) # "a-b-c"
" line1 \n line2 \n line3 " .splitlines() # ["line1", "line2", "line3"]
" hello world " .find( " world " ) # 6
" hello world " .index( " world " ) # 6
" hello world " .startswith( " hello " ) # True
" hello world " .replace( " world " , " python " ) # "hello python"
" hello world " .title() # "Hello World"
" hello world " .capitalize() # "Hello world"
# Classification (all return bool)
" abc123 " .isalnum() # True
Handle edge cases (empty strings, prefix longer than string) correctly.bool is a subclass of int. There are exactly two instances: True and FalseWhich are the Integer values 1 and 0 respectively.
print ( isinstance ( True , int )) # True
print ( sum ([ True , False , True ])) # 2
In boolean contexts (if``while``and``or``not``bool()), Python applies a well-defined set Of rules to determine the truth value of any object:
Value Truth Value Reason NoneFalse Explicit absence FalseFalse Boolean false Zero of any numeric type False 0``0.0``0j``Decimal(0)``Fraction(0, 1)Empty sequence/collection False ""``()``[]``{}``set()``range(0)Everything else True Including objects with __bool__ returning True
This behavior is governed by two dunder methods on every object:
__bool__(): Called first. Must return a bool.__len__(): Called if __bool__ is not defined. Returns True if __len__() returns nonzero. def __init__ ( self , items ):
print ( " empty " ) # prints "empty"
The and and or operators do not return booleans — they return one of their operands. This is a Deliberate design choice that enables concise conditional expressions.
# 'and' returns the first falsy value, or the last value if all are truthy
# 'or' returns the first truthy value, or the last value if all are falsy
" default " or None # "default"
This behavior is the basis of common Python idioms:
name = user_input or " Anonymous "
# Guard clause for optional dependencies
result = expensive_computation() if preconditions_met() else None
config = os.environ.get( " CONFIG_FILE " ) or default_path or " /etc/config.ini "
Short-circuit evaluation also means the right operand is not evaluated if the result is already Determined by the left operand. This is critical for avoiding errors:
if obj is not None and obj.attr:
pass # never raises AttributeError
result = value or compute_default() # compute_default() IS called
result = value or 0 # compute_default() is NOT called
None is the singleton instance of NoneType. It represents the absence of a value — Python’s Equivalent of null``nilOr NoneType in other languages.
print ( type ( None )) # <class 'NoneType'>
print ( None is None ) # True (identity, not equality)
There is exactly one None object in any Python process. This is guaranteed by the language Specification. The consequence is that identity comparison (is) is the correct way to check for None:
# Also works, but discouraged
# Wrong -- can be overridden by __eq__
Using is None rather than == None is important because a custom class can override __eq__ to Return True when compared to NoneWhich would be semantically incorrect. The is operator Cannot be overridden.
The most common None-related bug involves mutable default arguments. Because default argument Values are evaluated once at function definition time (not at call time), using a mutable default Causes all calls to share the same object.
# BUG : "items'' is created once and shared across all calls
def add_item ( item , items = []):
print (add_item( " a " )) # ["a"]
print (add_item( " b " )) # ["a", "b"] -- the list persists!
# CORRECT: use None as sentinel
def add_item ( item , items = None ):
print (add_item( " a " )) # ["a"]
print (add_item( " b " )) # ["b"] -- fresh list each time
Default arguments. The pattern `def f(arg=None): if arg is None: arg = ...` is the standard Solution.Python 3.5 introduced type hints via PEP 484, allowing optional static type annotations that External tools (mypy, pyright) can check without executing the code. Type hints do not affect Runtime behavior — they are completely ignored by the interpreter.
def greet ( name : str ) -> str :
names: list[ str ] = [ " Alice " , " Bob " ]
mapping: dict[ str , int ] = { " Alice " : 30 , " Bob " : 25 }
optional: str | None = None
Since Python 3.9, built-in collections (list``dict``set``tuple) can be used directly in type Annotations, replacing the typing module equivalents:
# Python 3.9+ (preferred)
def process ( items : list[ str ]) -> dict[ str , int ]:
return {item: len (item) for item in items}
from typing import List, Dict
def process ( items : List[ str ]) -> Dict[ str , int ]:
return {item: len (item) for item in items}
TypeVar enables writing generic functions where the relationship between input and output types Must be preserved:
from typing import TypeVar
def first ( items : list[T]) -> T:
raise ValueError ( " empty list " )
# The type checker infers T = int from the argument
x: int = first([ 1 , 2 , 3 ])
# The type checker infers T = str from the argument
y: str = first([ " a " , " b " , " c " ])
A TypeVar can be constrained to a specific set of types:
from typing import TypeVar
SupportsStr = TypeVar( " SupportsStr " , str , bytes )
def concat ( a : SupportsStr, b : SupportsStr) -> SupportsStr:
return a + b # type checker knows + is valid for both str and bytes
Or bounded by a base type:
from typing import TypeVar
T = TypeVar( " T " , bound = Animal)
def get_name ( obj : T) -> str :
return obj.name # type checker knows obj has .name
Python”s type system supports both nominal typing (based on inheritance) and structural typing (based on shape). Protocol enables structural typing — a type satisfies a protocol if it has the Required attributes and methods, regardless of inheritance.
from typing import Protocol
class SupportsClose ( Protocol ):
def close ( self ) -> None : ...
print ( " connection closed " )
def shutdown ( resource : SupportsClose) -> None :
shutdown(FileResource()) # OK -- has .close()
shutdown(NetworkConnection()) # OK -- has .close()
shutdown(InvalidResource()) # type error -- no .close()
This is Python’s formalization of duck typing. It allows type-checked code to remain as flexible as Runtime duck typing while providing static guarantees.
from typing import Callable
def apply ( func : Callable[[ int , int ], int ], a : int , b : int ) -> int :
apply( lambda x , y : x + y, 1 , 2 ) # OK
Union types have a concise syntax since Python 3.10:
# Python 3.10+ (preferred)
def process ( value : int | str | None ) -> str :
if isinstance (value, int ):
if isinstance (value, str ):
def process ( value : Union[ int , str , None ]) -> str :
When you know more about a type than the type checker does, use cast to assert the type without Any runtime overhead:
from typing import cast, Any
data: Any = get_external_data()
names: list[ str ] = cast(list[ str ], data)
cast is a no-op at runtime. It exists solely to communicate intent to the type checker.
mypy is the reference static type checker for Python. It analyzes source code without executing it And reports type errors.
Flag Purpose --strictEnable all optional checks --disallow-untyped-defsRequire type annotations on all function definitions --no-implicit-optionalDo not treat None as compatible with untyped defaults --warn-return-anyWarn when a function returns Any --ignore-missing-importsSuppress errors for untyped third-party libraries
ignore_missing_imports = true
For third-party libraries without type hints, you can write stub files. A stub file has the same Module path as the library but with a .pyi extension and contains only type signatures — no Implementations.
def process ( data : list[ int ]) -> dict[ str , int ]: ...
def connect ( host : str , port : int = 5432 ) -> Connection: ...
Type checkers are best-effort static analysis tools. They have inherent limitations:
No runtime enforcement. Type hints are annotations, not constraints. mypy catches errors at development time, but a misconfigured CI pipeline means errors can reach production.Any is infectious. Once a value has type AnyIt propagates through the entire call graph, effectively disabling type checking for anything that touches it.Dynamically dispatched code is hard to type. getattr``__getattr__And **kwargs defeat static analysis.Generics are erased at runtime. list[str] and list[int] are both list at runtime. isinstance(x, list[str]) raises a TypeError.# This raises TypeError at runtime -- generics are erased
isinstance ([ 1 , 2 , 3 ], list[ int ]) # TypeError: isinstance() argument 2 cannot be a parameterized generic
# Use isinstance with the raw type
isinstance ([ 1 , 2 , 3 ], list ) # True
In Python, x = 5 does not declare a variable named x of type int. It creates a name x in the Current scope and binds it to the object 5. The name is an entry in the current namespace’s Dictionary; the object exists independently on the heap.
b = a # 'b' and 'a' point to the SAME list object
print (a) # [1, 2, 3, 4] -- 'a' sees the change
This is the most fundamental concept in Python’s object model: names are references to objects, Not containers for values . Understanding this eliminates the majority of beginner confusion about Python’s behavior.
# Swap without a temporary variable
x, y = y, x # the right side is evaluated first as a tuple
# Extended unpacking (Python 3+)
first, * rest = [ 1 , 2 , 3 , 4 , 5 ]
print (rest) # [2, 3, 4, 5]
head, * middle, tail = [ 1 , 2 , 3 , 4 , 5 ]
print (middle) # [2, 3, 4]
Augmented assignment operators (+=``-=``*=Etc.) are syntactic sugar, but they are not always Equivalent to the expanded form. The key difference is that x += y calls __iadd__ if it exists, Which allows in-place modification:
a += [ 4 ] # calls list.__iadd__, modifies 'a' in place
print (a is b) # True -- same object
a = a + [ 5 ] # calls list.__add__, creates a new list
print (a is b) # False -- new object
For immutable types (int``str``tuple), there is no __iadd__So += is equivalent to = + — it always creates a new object.
Python resolves names using the LEGB rule , searching namespaces in this order:
L ocal — the current functionE nclosing — enclosing functions (for nested functions)G lobal — the module-level namespaceB uilt-in — the builtins module (print``len``rangeEtc.)Assignment to a name inside a function creates a local variable by default, even if a name with the Same spelling exists in an outer scope. Use global or nonlocal to override this.
global count # refers to the module-level 'count'
nonlocal count # refers to 'count' in the enclosing function
Difficult to test and reason about. Prefer passing state explicitly through function parameters or Using classes.Python variables are not boxes that contain values — they are labels stuck on objects. When you write x = [1, 2, 3], you are not putting a list inside x; you are sticking the label x on a list object that lives somewhere in memory. This is why a = b does not copy the list — both labels point to the same object. Dynamic typing means these labels can be moved to different objects at any time. Strong typing means Python will not silently convert incompatible types — it raises an error instead, because silent conversion would be like a librarian reorganizing your books without telling you. The immutable nature of strings means every “modification” creates a new string, like photocopying a page and writing on the copy.
Confusing an algorithm with a program. An algorithm is a step-by-step procedure, not its implementation in code.
Confusing authentication (who you are) with authorisation (what you can do) in security contexts.
Misunderstanding the difference between a stack (LIFO) and a queue (FIFO) in data structure applications.
Forgetting edge cases in algorithm design (e.g., empty input, single element, already sorted data).
The key principles covered in this topic are linked in the sub-pages above. Focus on understanding the definitions, applying the formulas or frameworks, and evaluating strengths and limitations of each approach.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Control Flow — Truthiness rules and short-circuit evaluation govern how booleans behave in conditional expressions and loops.Collections — Lists, tuples, and dicts are the primary data structures whose type properties depend on mutability and hashability.Python Internals — Integer caching, string interning, and the PyObject header explain the runtime behaviour of types introduced here.Dicts, Sets, and Collections Deep Dive — The type hierarchy and immutability concepts are prerequisites for understanding dict key and set membership requirements.