Python is an open-source, high-level and general-purpose programming language. It is dynamically type-checked (type safety of a program is verified at runtime) and garbage-collected.
Note: We are using Python 3
-
Python Fundamentals
-
Control Flow
-
Functions
-
Python Memory Model and Variable Behavior
-
Built-in Data Structures
-
File Handling
-
Error Handling
-
Modules and Packages
Python emphasizes code readability and relies on indentation to define code blocks instead of using braces {} like many other programming languages.
print("Hello, World!")
if 5 > 2:
print("Five is greater than two!")We can write a single line comment using # and multi-line comments using triple quotes ''' or """.
# This is a comment
'''
This is a multi-line comment
'''
"""
This is also a multi-line comment
"""Variables are containers for storing data values. Python is dynamically typed, meaning you don’t need to declare a variable’s type; it is determined automatically based on the value assigned.
x = 5
y = "Hello, World!"Python provides a variety of built-in data types, categorized as follows:
-
Text Type:
str
-
Numeric Types:
intfloatcomplex
-
Sequence Types:
list(mutable sequence of items)tuple(immutable sequence of items)range(sequence of numbers generated on demand)
-
Mapping Type:
dict(key-value pairs)
-
Set Types:
set(unordered collection of unique items)frozenset(immutable version ofset)
-
Boolean Type:
bool(representsTrueorFalse)
-
Binary Types:
bytes(immutable sequence of bytes)bytearray(mutable sequence of bytes)memoryview(views over memory buffers)
-
None Type:
None(represents the absence of a value)
Type conversion, also known as type casting, involves changing the data type of a value. In Python, this can occur implicitly or explicitly
Python automatically converts one data type to another without the programmer's intervention. This usually happens when performing operations between different data types, ensuring no data loss occurs. For instance, when adding an integer and a float, Python will convert the integer to a float before performing the addition.
num_int = 123
num_float = 1.23
num_new = num_int + num_float
print("datatype of num_int:",type(num_int))
print("datatype of num_float:",type(num_float))
print("Value of num_new:",num_new)
print("datatype of num_new:",type(num_new))Programmers manually convert the data type using built-in functions. This is necessary when specific data types are required for certain operations or when data loss is acceptable.
-
int(): Converts to an integer. -
float(): Converts to a floating-point number. -
str(): Converts to a string. -
list(),tuple(),set(): Converts to list, tuple and set, respectively.
x = 5
y = float(x)
z = str(x)Python provides the type() function to check the type of an object.
x = 5
print(type(x))
# Output: <class 'int'>We can use the input() function to take user inputs and use print() function to display text, variables and expressions on the console. By default input function takes user input as string.
name = input("Enter your name: ")
print("Hello ", name)
x, y, z = input("Enter the x-y-z coordinates: ").split()There are other ways to get input like
stdinandstdoutinsysmodule.
Python provides a wide range of operators to perform various operations. These are categorized as follows:
Used to perform basic mathematical operations.
| Operator | Description | Example | Output |
|---|---|---|---|
+ |
Addition | 5 + 3 | 8 |
- |
Subtraction | 5 - 3 | 2 |
* |
Multiplication | 5 * 3 | 15 |
/ |
Division | 10 / 2 | 5 |
% |
Modulus | 10 % 3 | 1 |
// |
Floor Division | 10 // 3 | 3 |
** |
Exponentiation | 5 ** 3 | 125 |
Used to compare values and return a Boolean (True or False).
| Operator | Description | Example | Output |
|---|---|---|---|
== |
Equal to | 5 == 3 |
False |
!= |
Not Equal to | 5 != 3 |
True |
> |
Greater Than | 5 > 3 |
True |
< |
Less Than | 5 < 3 |
False |
>= |
Greater Than or Equal To | 5 >= 3 |
True |
<= |
Less Than or Equal To | 5 <= 3 |
False |
Used to assign values to variables and perform shorthand operations.
| Operator | Description | Example | Equivalent |
|---|---|---|---|
= |
Assign | x = 5 |
x = 5 |
+= |
Add and Assign | x += 3 |
x = x + 3 |
-= |
Subtract and Assign | x -= 3 |
x = x - 3 |
*= |
Multiply and Assign | x *= 3 |
x = x * 3 |
/= |
Divide and Assign | x /= 3 |
x = x / 3 |
%= |
Modulus and Assign | x %= 3 |
x = x % 3 |
//= |
Floor Division and Assign | x //= 3 |
x = x // 3 |
**= |
Exponentiation and Assign | x **= 3 |
x = x ** 3 |
Used to combine conditional statements.
| Operator | Description | Example | Output |
|---|---|---|---|
and |
Logical AND | True and False |
False |
or |
Logical OR | True or False |
True |
not |
Logical NOT | not True |
False |
Operate on binary representations of integers.
| Operator | Description | Example | Output |
|---|---|---|---|
& |
Bitwise AND | 5 & 3 |
1 |
| |
Bitwise OR | 5 | 3 |
7 |
^ |
Bitwise XOR | 5 ^ 3 |
6 |
~ |
Bitwise NOT | ~5 |
-6 |
<< |
Left Shift | 5 << 1 |
10 |
>> |
Right Shift | 5 >> 1 |
2 |
-
Bitwise XOR (
^) operator returns true if the bits are different, otherwise returns false. -
Bitwise NOT operator flips the bits of the number. It can also change the sign of the number.
- In most programming languages negative numbers are represented using the 2's complement system. To find the decimal value of a 2's complement binary number:
- If the leading bit is zero; The number is positive and you can directly convert the binary to decimal.
- If the leading bit is one; The number is negative. To find its magnitude:
- Invert all bits.
- Add one to the result.
- The decimal value is the negative of this result.
- In most programming languages negative numbers are represented using the 2's complement system. To find the decimal value of a 2's complement binary number:
-
Left shift operator shifts the bits to the left by the specified number of positions, filling the rightmost bits with zeros. Mathematically, this is equivalent to multiplying the number by 2 raised to the power of the number of positions shifted.
-
Right shift operator shifts the bits to the right by the specified number of positions, discarding the rightmost bits. Mathematically, this is equivalent to dividing the number by 2 raised to the power of the number of positions shifted.
Used to check if a value is part of a sequence (e.g., string, list, tuple, etc.).
| Operator | Description | Example | Output |
|---|---|---|---|
in |
Present in | 5 in [1, 2, 3, 4, 5] |
True |
not in |
Not Present in | 5 not in [1, 2, 3, 4, 5] |
False |
Used to compare the memory locations of two objects.
| Operator | Description | Example | Output |
|---|---|---|---|
is |
Same Object | x is y |
True |
is not |
Different Object | x is not y |
False |
Conditional statements in Python are used to execute specific blocks of code based on logical conditions. Python supports the following conditional statements:
Executes a block of code if the condition evaluates to True.
x = 10
if x > 5:
print("x is greater than 5")
# Output: x is greater than 5Allows checking multiple conditions. It is short for "else if."
x = 10
if x > 15:
print("x is greater than 15")
elif x > 5:
print("x is greater than 5 but less than or equal to 15")
# Output: x is greater than 5 but less than or equal to 15Executes a block of code if none of the preceding conditions are True.
x = 2
if x > 5:
print("x is greater than 5")
else:
print("x is 5 or less")
# Output: x is 5 or lessAllows placing an if statement inside another if statement to check multiple conditions hierarchically.
x = 10
if x > 5:
if x % 2 == 0:
print("x is greater than 5 and even")
else:
print("x is greater than 5 and odd")
# Output: x is greater than 5 and evenresult = a if condition else bIf condition is True, the value of
ais assigned to result. Otherwise, the value ofbis assigned.
x = 10
y = 20
max_value = x if x > y else y
print(max_value) # Output: 20Loops in Python are used to execute a block of code repeatedly as long as a condition is met or for each item in a sequence. Python supports the following loop constructs:
A while loop runs as long as its condition evaluates to True.
i = 1
while i <= 5:
print(i)
i += 1
# Output:
# 1
# 2
# 3
# 4
# 5A for loop iterates over a sequence (like a list, tuple, string, or range).
for i in range(1, 6):
print(i)
# Output:
# 1
# 2
# 3
# 4
# 5We can use the break statement to stop the loop before it has looped through all the items, and the continue statement to stop the current iteration of the loop, and continue with the next.
Python loops also have something like break statement.
# for else
for i in range(1, 6):
if i == 7:
break
else:
print("Loop completed without a break")
# Output: Loop completed without a break
# while else
i = 1
while i <= 5:
i += 1
if i == 2:
continue
print(i)
else:
print("Loop completed without a break")
# 3
# 4
# 5
# 6
# Loop completed without a breakA function is a reusable block of code designed to perform a specific task. Functions allow modularity, code reuse, and better organization of programs. In Python, functions are defined using the def keyword.
The syntax for defining a function:
def function_name(parameters):
# Function body
return value
def greet(name):
return f"Hello, {name}"The syntax for calling a function is:
function_name(arguments)
print(greet("Doe"))
Parametersare what the function expects to receive (the variables listed inside the parentheses in the function definition).Argumentsare what you actually give to the function (the actual values that are passed to the function when you call it).
def add(a, b): # 'a' and 'b' are parameters
return a + b
print(add(5, 10)) # '5' and '10' are arguments- Multiple Return Values:
Python functions can return multiple values as a tuple.
def calculate(a, b):
return a + b, a - b
sum_result, diff_result = calculate(10, 5)
print(sum_result) # Output: 15
print(diff_result) # Output: 5- Default Parameters:
Functions can have default values for parameters.
def greet(name="Guest"):
return f"Hello, {name}!"
print(greet()) # Output: Hello, Guest!
print(greet("Alice")) # Output: Hello, Alice!- Variable-length Arguments:
-
*args: Allows passing a variable number of positional arguments.
-
**kwargs: Allows passing a variable number of keyword arguments.
def sum_numbers(*args):
return sum(args)
print(sum_numbers(1, 2, 3, 4)) # Output: 10
def display_info(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
display_info(name="Alice", age=25)
# Output:
# name: Alice
# age: 25- Stack and Heap Memory:
Stack and Heap here refers to two memory allocation concepts. Stack is used for static memory allocation; for storing function calls and local variables, while heap is used for dynamic memory allocation; storing objects and data that persist beyond a single function call.
def example_function(arg1, arg2, *args, kw_only_arg, **kwargs):
print(f"arg1: {arg1}")
print(f"arg2: {arg2}")
print(f"args: {args}")
print(f"kw_only_arg: {kw_only_arg}")
print(f"kwargs: {kwargs}")
# Calling the function
example_function(1, 2, 3, 4, 5, kw_only_arg="hello", name="Alice", age=30)Lambda functions are small, anonymous functions defined using the lambda keyword. They are limited to a single expression and are often used for short-term tasks.
lambda arguments: expression- Lambda functions are particularly useful with higher-order functions like
map(),filter(), andreduce().
nums = [1, 2, 3, 4]
squared = list(map(lambda x: x ** 2, nums))
print(squared) # Output: [1, 4, 9, 16]Namespace and scope are fundamental concepts in every programming languages. In python they govern how variables and names are organized and accessed within a program.
-
A
namespaceis a system that ensures all names in a program are unique and avoids naming conflicts. It's a collection of names (identifiers) mapped to their corresponding objects. Namespaces are implemented as dictionaries in Python. -
Scoperefers to the region of a program where a particular namespace is directly accessible. It determines the visibility and lifetime of names within that region.
Python has several scopes:
-
Local (Function): Variables defined inside a function have local scope. They are only accessible within that function.
-
Enclosing (Nonlocal): If a function is defined inside another function (nested function), the inner function can access variables from the outer function's scope. This is the enclosing scope.
-
Global (Module): Variables defined at the top level of a module (outside any function or class) have global scope. They can be accessed from anywhere within the module.
-
Built-in: This scope contains pre-defined functions and constants that are always available in Python.
When you refer to a name in your Python code, the interpreter searches for that name in a specific order through different scopes. This order is known as the LEGB rule:
-
L: Local: Search the local scope first.
-
E: Enclosing: If the name is not found locally, search the enclosing function's scope.
-
G: Global: If not found in the enclosing scope, search the global scope.
-
B: Built-in: Finally, if the name is not found in any of the previous scopes, search the built-in scope.
If a name is not found in any of these scopes, Python raises a
NameError.
-
global: Declares a variable inside a function as referring to the global scope. -
nonlocal: Used in nested functions to refer to variables in the enclosing (non-global) scope.
x = 10 # global
def outer():
y = 5 # enclosing
def inner():
nonlocal y
global x
y += 1
x += 1
print("Inner y:", y, "Global x:", x)
inner()
print("Outer y:", y)
outer()
print("Final x:", x)In Python, everything is an object — from integers and strings to lists and functions. Variables act as references (bindings) to these objects, not as direct storage locations for data.
When you assign a value to a variable, Python creates an object in memory and binds the variable name to it.
n = 300Here:
-
An integer object with value 300 is created.
-
The variable
nreferences this object
Every Python object has a unique identifier, typically its memory address, which can be checked using id().
n = 300
print(id(n))When you reassign a variable, it simply points to a new object.
n = 300
n = "foo"Now, n no longer refers to the integer 300; it refers to the string "foo".
Python optimizes memory usage by reusing immutable objects with the same value.
a = 10
b = 10
print(a is b) # True (same object)Both a and b reference the same integer object in memory.
- Always use
isto compare object identity (same object in memory), and==for value equality.
Python’s memory manager handles:
-
Object allocation on the heap
-
Reference counting
-
Garbage collection for unreferenced objects
Each object keeps track of how many variables reference it. When the reference count drops to zero, Python automatically frees that memory. You can manually delete a reference using del:
x = [1, 2, 3]
del x # reduces reference countGarbage collection is handled by Python’s built-in gc module, which also removes cyclic references (e.g., objects referencing each other).
-
Understand reference counting is crucial for debugging memory leaks
-
Use tools like
sys.getrefcount()and the gc module to analyze memory usage.
A common confusion in Python:
“Is Python pass-by-value or pass-by-reference?”
The answer is neither.
Python uses Parameter passing mechanism or (Pass by Object Reference (or Pass by Assignment)).
This means:
-
The function receives a reference to the object, not the object itself.
-
The behavior depends on mutability of the object.
When you pass an immutable object (int, str, tuple), reassigning it inside a function creates a new object.
def modify_value(x):
x += 10
print("Inside function:", x)
a = 5
modify_value(a)
print("Outside function:", a)
# Inside function: 15
# Outside function: 5- The original variable
aremains unchanged.
When you pass a mutable object (list, dict, set), modifications inside the function affect the original.
def modify_list(lst):
lst.append(10)
print("Inside function:", lst)
nums = [1, 2, 3]
modify_list(nums)
print("Outside function:", nums)
# Inside function: [1, 2, 3, 10]
# Outside function: [1, 2, 3, 10]- The function directly modifies the list object referenced by
nums.
Since everything in Python is an object, even primitive-looking types (like int and float) are actually boxed objects.
-
Boxing : Wrapping a raw value inside an object
-
Unboxing : Extracting the underlying value from an object for operations
a = 10
b = 20
c = a + bBehind the scenes:
-
Python verifies the operand types (
int). -
Calls the appropriate magic method (
__add__). -
Unboxes the integer values (
10,20). -
Performs the addition.
-
Boxes the result (
30) back into a new integer object.
Although flexible, this process adds overhead, making Python slower than low-level languages like C.
For performance, Python preallocates and caches small integers in the range [-5, 256].
a = 100
b = 100
print(a is b) # True (cached)
x = 1000
y = 1000
print(x is y) # False (new objects)This optimization reduces object creation overhead, as these values are frequently used in most programs.
Unlike many languages, Python’s integers are arbitrary-precision. They can grow as large as memory allows — no overflow errors.
huge = 10**100
print(huge)Internally, Python dynamically allocates additional memory to store large numbers, using a variable-length representation.
Cython is an optimizing static compiler that allows users to write C extensions for Python. It is a superset of the Python language, meaning it includes all Python functionality and adds the ability to integrate C-level features.
cpdef int add(int x, int y):
cdef int result
result = x + y
return resultBy declaring types, Cython avoids Python’s dynamic type checks and boxing/unboxing overhead, leading to significant performance gains.
A string is an immutable sequence of Unicode characters enclosed in single quotes ('), double quotes ("), or triple quotes (''' """).
-
Strings are immutable (cannot be modified once created). Any operation that alters a string creates a new string object.
-
Strings are ordered, indexed (starting at
0and also support negative indexing), iterable and can contain duplicate elements. -
Strings can be sliced (
[start : stop : step]) and supports membership operations.
-
Concatenation:
+operator -
Repetition:
*operator -
Membership Testing:
in,not in -
Comparison: Lexicographic (based on Unicode code points)
a, b = "Hello", "World"
print(a + " " + b) # Hello World
print(a * 3) # HelloHelloHello
print("H" in a) # True
print("hello" < "world") # True (lexicographic order)-
Case Conversion
.upper(),.lower(),.capitalize(),.title(),.swapcase()
-
Searching and Checking
-
.find(),.rfind(),.index(),.startswith(),.endswith() -
.count(),.isalnum(),.isalpha(),.isdigit(),.isspace(),.istitle()
-
-
Modification
-
.replace(),.strip(),.lstrip(),.rstrip() -
.center(),.ljust(),.rjust() -
.zfill()
-
-
Splitting and Joining
-
.split(),.rsplit(),.splitlines() -
.join(iterable)
-
-
Unicode Encodings
-
ord(char)→ Returns Unicode code point of a character -
chr(code)→ Returns character for a Unicode code point -
encode()→ Converts string to bytes -
decode()→ Converts bytes to string
print(ord("A")) # 65 print(chr(65)) # A s = "Python 🐍" encoded = s.encode("utf-8") decoded = encoded.decode("utf-8") print(encoded) # b'Python \xf0\x9f\x90\x8d' print(decoded) # Python 🐍
-
Python 3.6+
Efficient and easier way to do string formatting.
print(f"Name: {name}, Age: {age}")
print(f"Next year: {age + 1}")- String concatenation with
+inside loops is inefficient (creates new objects each time). Usestr.join()orio.StringIOinstead.
# Inefficient
res = ""
for i in range(1000):
res += str(i)
# Efficient
res = "".join(str(i) for i in range(1000))-
Time complexity of string operations
- Indexing :
$O(1)$ - Slicing :
$O(k)$ wherekis the length of the slice - Concatenation :
$O(n)$ for strings of lengthn
- Indexing :
-
Python caches small strings, commonly short identifiers, called string interning.
a = "hello"
b = "hello"
print(a is b) # True (due to interning)
a = "Strings in Python are powerful and versatile."
b = "Strings in Python are powerful and versatile."
print(a is b) # FalsePython lists are ordered, mutable collections of objects. They can store heterogenous types.
-
Lists are indexed, dynamic and allows duplicate elements.
-
Defined using square brackets
[]or thelist()constructor.
nums = [1, 2, 3, 4]
mixed = [1, "hello", 3.14, [5, 6]]
print(nums[0]) # 1
print(nums[-1]) # 4
print(nums[1:3]) # [2, 3]Array Internal Representation
-
Concatenation:
+ -
Repetition:
* -
Membership Testing:
in,not in -
Unpacking: Direct assignment of elements
a = [1, 2, 3]
b = [4, 5]
print(a + b) # [1, 2, 3, 4, 5]
print(a * 2) # [1, 2, 3, 1, 2, 3]
x, y, z = a
print(x, y, z) # 1 2 3-
Adding / Removing Elements
-
.append(x)→ Add element at end -
.extend(iterable)→ Add all elements from iterable -
.insert(i, x)→ Insert at position i -
.remove(x)→ Remove first occurrence -
.pop([i])→ Remove and return element at index i (last by default) -
.clear()→ Remove all elements
-
-
Searching and Counting
-
.index(x, [start], [end])→ Find index of first occurrence -
.count(x)→ Count occurrences
-
-
Sorting and Reversing
-
.sort(key=None, reverse=False)→ In-place sort -
sorted(iterable, key=None, reverse=False)→ Returns a new sorted list -
.reverse()→ Reverse in-place -
reversed(list)→ Returns an iterator
-
-
Copying
-
.copy()or[:]orlist(): Shallow copy (It creates a new list container but simply copies the references to the items within the original list. Both lists therefore point to the same internal objects.) Therefore modifying a mutable object within the shallow-copied list will also change the original list -
copy.deepcopy(): A deep copy creates a completely independent new list. It recursively duplicates all objects it encounters, from the list itself to all the objects contained within it, and all the objects within those objects, and so on. Changes made to the deep-copied list will not affect the original list, and vice versa.
import copy a = [[1, 2], [3, 4]] b = a.copy() c = copy.deepcopy(a) a[0][0] = 99 print(b) # [[99, 2], [3, 4]] (affected) print(c) # [[1, 2], [3, 4]] (independent)
-
squares = [x**2 for x in range(5)]
print(squares) # [0, 1, 4, 9, 16]
evens = [x for x in range(10) if x % 2 == 0]
print(evens) # [0, 2, 4, 6, 8]matrix = [[1, 2], [3, 4], [5, 6]]
print(matrix[1][0]) # 3-
enumerate(list)→ Returns index + value pairs -
zip(list1, list2)→ Combines lists element-wise -
*argsunpacking in function calls
Lists are implemented as dynamic arrays in CPython. Common time complexities are;
| Operation | Complexity |
|---|---|
| Indexing | O(1) |
| Append | O(1) (amortized) |
| Pop (end) | O(1) |
| Insert / Pop (anywhere else) | O(n) |
Membership test (x in list) |
O(n) |
| Slicing | O(k) where k = slice length |
| Sort | O(n log n) |
-
For large numeric arrays, prefer
array.arrayornumpy.ndarrayfor efficiency.
Tuples are an immutable, ordered sequence type in Python. They are widely used for grouping related data, ensuring data integrity, and improving performance when immutability is desirable.
-
They can be defined using parenthesis (( )) or built-in tuple() constructor.
-
They allow duplicate elements and can store heterogenous data types.
# Creating tuples
t1 = (1, 2, 3)
t2 = ("apple", "banana", "cherry")
t3 = (1, "hello", 3.14, True)
# Single element tuple (needs trailing comma!)
t_single = (5,) # not (5)- Indexing and slicing are same as lists
t = (10, 20, 30, 40, 50)
print(t[0]) # 10
print(t[-1]) # 50
print(t[1:4]) # (20, 30, 40)- Concatenation and repetition
a = (1, 2)
b = (3, 4)
print(a + b) # (1, 2, 3, 4)
print(a * 3) # (1, 2, 1, 2, 1, 2)- Membership test
print(2 in a) # True
print(5 not in b) # True-
Tuples support only two built-in methods (since they are immutable):
.count(value): Returns occurrences of a value.index(value): Returns first index of the value
t = (1, 2, 2, 3, 4) print(t.count(2)) # 2 print(t.index(3)) # 3
-
Tuple Packing: Assign multiple values at once.
-
Tuple Unpacking: Extract values into variables.
# Packing
point = (3, 4)
# Unpacking
x, y = point
print(x, y) # 3 4
# Extended unpacking
a, *b, c = (1, 2, 3, 4, 5)
print(a, b, c) # 1 [2, 3, 4] 5Tuples can contain other tuples or even mutable objects. Therefore the tuples itself are immutable, but if they hold mutable object, that object can still be changed. If a given tuple only contains immutable data, they would be hashable and hence can be used as dictionary keys.
coords = {}
coords[(10, 20)] = "A"
coords[(15, 25)] = "B"
print(coords) # {(10, 20): 'A', (15, 25): 'B'}Collections module provide a factory function called namedtuple, that creates tuple subclasses with named fields. It offers a way to combine the immutability and memory efficiency of regular tuples with enhanced readability of accessing elements by name instead of numerical index.
from collections import namedtuple
# Define a named tuple type for a Point
Point = namedtuple('Point', ['x', 'y'])
# Create an instance of the Point named tuple
p = Point(10, 20)
# Access elements by name
print(f"X coordinate: {p.x}")
print(f"Y coordinate: {p.y}")
# Access elements by index (still works)
print(f"First element by index: {p[0]}")-
Memory efficient : Tuples uses less memory than lists
-
Faster Iteration : Due to their immutability, Python can optimize their usage.
-
Tuple Interning : Small immutable tuples may be cached by python, similar to string interning
A set is an unordered, mutable, and un-indexed collection of unique elements in Python. Sets are useful for membership testing, removing duplicates, and performing mathematical set operations and allows faster insertion, deletion and searching. They are implemented using hash tables.
- Elements of set must be hashable (immutable).
# Creating sets
s1 = {1, 2, 3, 4}
s2 = set([3, 4, 5, 6]) # converting list to set
print(s1) # {1, 2, 3, 4}
print(s2) # {3, 4, 5, 6}
# Empty set
empty = set() # not {}- Membership Test (Very fast, O(1) average time)
s = {10, 20, 30}
print(10 in s) # True
print(40 not in s) # True- Union
a = {1, 2, 3}
b = {3, 4, 5}
print(a | b) # {1, 2, 3, 4, 5}
print(a.union(b)) # {1, 2, 3, 4, 5}- Intersection
print(a & b) # {3}
print(a.intersection(b)) # {3}- Difference
print(a - b) # {1, 2}
print(b - a) # {4, 5}- Symmetric Difference
print(a ^ b) # {1, 2, 4, 5}- Adding / Removing elements
s = {1, 2}
s.add(3) # {1, 2, 3}
s.update([4, 5]) # {1, 2, 3, 4, 5}
s.remove(2) # removes element, raises KeyError if not present
s.discard(10) # safe remove (no error if not found)
s.pop() # removes a random element
s.clear() # empties the set- Copy
s1 = {1, 2, 3}
s2 = s1.copy()It is the immutable version of set. Since frozen set itself is hashable, it can be used as dictionary keys or elements of another set.
fs = frozenset([1, 2, 3])
print(fs) # frozenset({1, 2, 3})
# fs.add(4) # Error: 'frozenset' object has no attribute 'add'- Set Comprehension
s = {x**2 for x in range(5)}
print(s) # {0, 1, 4, 9, 16, 25}- Subset / Superset checks
a = {1, 2}
b = {1, 2, 3}
print(a.issubset(b)) # True
print(b.issuperset(a)) # True-
Suitable for large-scale lookups and deduplication.
-
Sets are extremely helpful in situations like doing a membership test for blacklist/whitelist or deduplicating user ID's/logs.
-
We use sets for tracking visited nodes in graph algorithms.
A dictionary is an unordered (From Python 3.7 onwards, Dictionaries are officially insertion ordered), mutable, and key-value pair data structure in Python. It allows for fast lookups, insertions, and deletions, with average O(1) time complexity.
-
Defined using curly braces
{}or thedict()constructor. -
Keys must be unique and immutable (e.g., strings, numbers, tuples).
-
Values can be any object (mutable or immutable).
# Creating dictionaries
person = {"name": "Alice", "age": 25, "city": "London"}
# Using dict() constructor
info = dict(language="Python", version=3.12)
# Empty dictionary
empty = {}person = {"name": "Alice", "age": 25, "city": "London"}
# Accessing values
print(person["name"]) # Alice
print(person.get("age")) # 25
print(person.get("salary", "Not Found")) # Default value
# Modifying values
person["age"] = 26
person["country"] = "UK" # Adding new key-value pair
print(person)get()is preferred for safe access to avoid KeyError.
- Membership tests
print("name" in person) # True
print("Alice" in person) # False- Deleting entries
del person["city"] # Removes key
removed = person.pop("age") # Removes key and returns its value
person.clear() # Empties dictionary| Method | Description | Example |
|---|---|---|
.keys() |
Returns view of keys | dict.keys() |
.values() |
Returns view of values | dict.values() |
.items() |
Returns key-value pairs as tuples | dict.items() |
.update(other) |
Merges another dictionary | d1.update(d2) |
.popitem() |
Removes and returns last inserted pair | d.popitem() |
.copy() |
Returns a shallow copy | d2 = d.copy() |
.setdefault(key, default) |
Inserts key with default value if not present | d.setdefault('role', 'user') |
student = {"name": "Bob", "grade": "A"}
# Using items()
for key, value in student.items():
print(f"{key}: {value}")It is similar to list comprehension, but produce key-value pairs.
squares = {x: x**2 for x in range(5)}
print(squares) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# filtering example
nums = {x: x**2 for x in range(10) if x % 2 == 0}
print(nums) # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64}Dictionaries can hold other dictionaries as values, allowing for hierarchical data representation.
employees = {
"E001": {"name": "Alice", "dept": "HR"},
"E002": {"name": "Bob", "dept": "IT"}
}
print(employees["E002"]["name"]) # Bobd1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
merged = d1 | d2 # {'a': 1, 'b': 3, 'c': 4}d3 = {**d1, **d2}from collections import defaultdict
grades = defaultdict(list)
grades["Alice"].append(90)
grades["Bob"].append(85)
print(grades) # defaultdict(<class 'list'>, {'Alice': [90], 'Bob': [85]})-
Dictionaries are implemented using hash tables, average complexity is
$O(1)$ for lookup, insertion and deletion and$O(n)$ for iteration.
File handling allows Python programs to interact with files stored on disk — reading, writing, and manipulating data persistently.
In Python, files are opened using the built-in open() function:
file = open("example.txt", "r") # 'r' = read mode
content = file.read()
print(content)
file.close()| Mode | Description | Notes |
|---|---|---|
'r' |
Read (default) | File must exist |
'w' |
Write | Overwrites existing content |
'a' |
Append | Adds new data at the end |
'x' |
Exclusive creation | Fails if file exists |
'r+' |
Read and write | File must exist |
'w+' |
Write and read | Overwrites file |
'a+' |
Append and read | Creates file if missing |
'b' |
Binary mode | Used with other modes ('rb', 'wb') |
- Reading a File
with open("notes.txt", "r") as f:
data = f.read() # Reads entire file
print(data)
# other read methods
f.readline() # Reads one line
f.readlines() # Reads all lines as a list- Writing to a file
with open("output.txt", "w") as f:
f.write("Hello, World!\n")
f.write("This overwrites existing content.")- Appending to a file
with open("output.txt", "a") as f:
f.write("\nNew line appended.")Binary files (e.g., images, audio, executables) require reading/writing in binary mode ('b').
# Copying an image file
with open("photo.jpg", "rb") as src:
data = src.read()
with open("photo_copy.jpg", "wb") as dest:
dest.write(data)- Python does not interpret binary data — it simply reads and writes bytes (
b'\x...').
Using with ensures that files are automatically closed after the code block is executed, even if an exception occurs.
Context managers in Python are objects that define a runtime context for use with the
withstatement. They provide a mechanism for automatically managing resources, ensuring that setup and cleanup operations are performed correctly, even if errors occur within the code block.
Python provides two main modules for handling file paths:
- Using
osmodule
import os
print(os.getcwd()) # Get current directory
print(os.listdir(".")) # List files
print(os.path.exists("file.txt")) # Check if file exists
print(os.path.join("folder", "file.txt")) # Join paths safely- Using
pathlib(Modern Way)
from pathlib import Path
p = Path("example.txt")
if p.exists():
print(p.read_text()) # Directly read file content
p.write_text("This is new text") # Write to file
# Directory operations
folder = Path("data")
folder.mkdir(exist_ok=True)pathlibis object-oriented, more readable, and recommended for new Python code.
Reading the entire content of a large file into memory using the file object's read() method can be highly inefficient and potentially cause your program to crash due to an OutOfMemoryError or MemoryError.
- Line-by-line Iteration
with open("bigfile.txt", "r") as f:
for line in f: # file object 'f' is an iterator
# process the 'line' here- This approach reads one line at a time, conserving memory.
When a file object (
f) is used directly in a for loop, it acts as an iterator.
- Fixed size data chunks
Another common approach for non-text or structured files is to read data in fixed-size chunks.
with open("bigfile.bin", "rb") as f:
while chunk := f.read(4096) # Read 4096 bytes (4KB) at a time'
process(chunk)with open("video.mp4", "rb") as f:
chunk_size = 4096
while chunk := f.read(chunk_size):
process(chunk)- This pattern is common in data streaming or network applications.
- File Positioning
You can control the read/write pointer using seek() and tell():
f = open("sample.txt", "r")
print(f.tell()) # Current position
f.seek(0) # Move to start
print(f.readline()) # Read first line
f.close()- File Metadata
import os
info = os.stat("sample.txt")
print(f"Size: {info.st_size} bytes")
print(f"Modified: {info.st_mtime}")- File Deletion and Renaming
import os
os.rename("old.txt", "new.txt")
os.remove("new.txt")-
Use Buffered I/O (
io.BufferedReader,io.BufferedWriter) for large data streams. -
Prefer context managers over manual
open()/close()for safety. -
Avoid frequent small writes — instead, accumulate data and write in chunks.
-
Use binary mode for non-text data to prevent encoding overhead.
-
Use memory mapping (
mmap) for high-speed file access:
import mmap
with open("largefile.txt", "r+") as f:
with mmap.mmap(f.fileno(), 0) as mm:
print(mm.readline().decode())- Memory mapping treats file content as a byte array in memory — ideal for large file manipulation.
Always handle exceptions — file operations are prone to runtime errors (missing file, permissions, I/O failures).
try:
with open("config.yaml", "r") as file:
data = file.read()
except FileNotFoundError:
print("File not found.")
except PermissionError:
print("Permission denied.")
except Exception as e:
print(f"Unexpected error: {e}")Error handling in Python allows developers to gracefully detect, respond to, and recover from unexpected events that occur during program execution.
-
An error is an issue in a program that causes abnormal termination.
-
An exception is a special event raised during execution when an error occurs. We can catch and handle these exceptions dynamically.
| Type | Description | Example |
|---|---|---|
| Syntax Errors | Detected during parsing (before execution). | if True print("Hi") |
| Runtime Errors (Exceptions) | Detected during execution; can be caught and handled. | 1 / 0 (ZeroDivisionError) |
The try statement allows testing a block of code for errors, while except handles them.
try:
x = int(input("Enter a number: "))
result = 10 / x
except ZeroDivisionError:
print("❌ Cannot divide by zero.")
except ValueError:
print("❌ Please enter a valid number.")
else:
print("✅ Division successful:", result)
finally:
print("🧹 Execution completed.")try: code that might raise an exceptionexcept: Code that runs if an exception occurselse: Code that runs only if no exception occursfinally: Code that always runs (cleanup, closing files, releasing resources)
We can catch multiple exceptions in one block using a tuple:
try:
val = int("abc")
except (ValueError, TypeError) as e:
print(f"Error occurred: {e}")| Exception | Description |
|---|---|
| ValueError | Raised when an operation receives an argument of right type but inappropriate value. |
| TypeError | Raised when an operation or function is applied to an object of inappropriate type. |
| KeyError | Raised when a dictionary key is not found. |
| IndexError | Raised when accessing an invalid list or tuple index. |
| ZeroDivisionError | Raised when dividing by zero. |
| FileNotFoundError | Raised when a file or directory is missing. |
| AttributeError | Raised when an invalid attribute reference occurs. |
| ImportError | Raised when an import fails. |
| RuntimeError | Raised for generic runtime errors. |
| ArithmeticError | Raised when an error occurs in numeric calculations |
| AssertionError | Raised when an assert statement fails |
| Exception | Base class for all exceptions |
| EOFError | Raised when the input() method hits an "end of file" condition (EOF) |
| FloatingPointError | Raised when a floating-point calculation fails |
| GeneratorExit | Raised when a generator is closed (with the close() method) |
| IndentationError | Raised when indentation is not correct |
| KeyboardInterrupt | Raised when the user presses Ctrl+c, Ctrl+z or Delete |
| LookupError | Raised when errors raised can't be found |
| MemoryError | Raised when a program runs out of memory |
| NameError | Raised when a variable does not exist |
| NotImplementedError | Raised when an abstract method requires an inherited class to override the method |
| OSError | Raised when a system related operation causes an error |
| OverflowError | Raised when the result of a numeric calculation is too large |
| ReferenceError | Raised when a weak reference object does not exist |
| StopIteration | Raised when the next() method of an iterator has no further values |
| SyntaxError | Raised when a syntax error occurs |
| TabError | Raised when indentation consists of tabs or spaces |
| SystemError | Raised when a system error occurs |
| SystemExit | Raised when the sys.exit() function is called |
| UnboundLocalError | Raised when a local variable is referenced before assignment |
| UnicodeError | Raised when a unicode problem occurs |
| UnicodeEncodeError | Raised when a unicode encoding problem occurs |
| UnicodeDecodeError | Raised when a unicode decoding problem occurs |
| UnicodeTranslateError | Raised when a unicode translation problem occurs |
You can handle exceptions at multiple levels or re-raise them for higher-level handling.
def divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
print("Caught inside function:", e)
raise # Re-raise exception
try:
divide(10, 0)
except ZeroDivisionError:
print("Handled again at caller level.")Custom exceptions are user-defined classes derived from Python’s Exception base class.
class NegativeValueError(Exception):
"""Raised when input is negative."""
pass
def square_root(x):
if x < 0:
raise NegativeValueError("Negative value not allowed.")
return x ** 0.5
try:
print(square_root(-9))
except NegativeValueError as e:
print(f"Custom Exception: {e}")- Always inherit from
Exception, not fromBaseException, to avoid interfering with system-level exceptions likeKeyboardInterrupt. Further, it's better to inherit from specific exception likeValueErroror whatever appropriate.
Python allows linking related exceptions using raise ... from ....
try:
int("abc")
except ValueError as e:
raise RuntimeError("Conversion failed.") from e- This helps preserve traceback context across multiple layers of failure.
Use the finally block or context managers (with statement) for safe cleanup.
try:
f = open("data.txt")
data = f.read()
finally:
f.close() # Ensures closure even on exception
# Pythonic code
with open("data.txt") as f:
data = f.read()withautomatically closes the resource using__enter__and__exit__methods, ensuring no resource leaks.
Instead of printing, log exceptions.
import logging
logging.basicConfig(level=logging.ERROR)
try:
1 / 0
except ZeroDivisionError as e:
logging.exception("An error occurred")- This records stack traces with timestamps — crucial for debugging in deployed systems.
| Aspect | Details |
|---|---|
| Raising Exceptions | Slightly expensive due to stack unwinding; avoid in performance-critical loops. |
| Control Flow | Don’t use exceptions for regular control logic — prefer condition checks. |
| Garbage Collection | Exception tracebacks hold references to frames; large exception logs can delay GC. |
| Optimized Alternative | For repeated validation, use if checks instead of raising exceptions repeatedly. |
- Use
tryblocks only around risky code segments, not entire functions. - When debugging, inspect the exception object with
repr(e)ortracebackmodule. - Use
contextlib.suppressto ignore specific exceptions intentionally:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("nonexistent.txt")Python’s modular architecture allows code to be divided into modules (single files) and packages (collections of modules). This promotes reusability, organization, and maintainability.
A module is simply a Python file (.py) containing variables, functions, or classes that can be imported into other programs.
# math_utils.py
def add(a, b):
return a + b
def multiply(a, b):
return a * bimport math_utils
print(math_utils.add(5, 3))
print(math_utils.multiply(4, 2))- Basic import
import math
print(math.sqrt(16))- Import with Alias
import numpy as np
np.array([1, 2, 3])- Import Specific Items
from math import sqrt, pi
print(sqrt(25), pi)- Import All (
⚠️ Not Recommended)
from math import *- Avoid
import *to prevent namespace pollution and ambiguity.
When importing, Python searches modules in a specific order defined by sys.path.
import sys
print(sys.path)Typical search order:
-
Current directory.
-
PYTHONPATHenvironment variable. -
Standard library directories.
-
Installed site-packages.
You can add custom paths dynamically:
import sys
sys.path.append("/path/to/your/modules")A package is a directory containing a special file named __init__.py, which marks it as a package.
- Example structure
project/
│
├── main.py
└── mypackage/
├── __init__.py
├── module1.py
└── module2.py# mypackage/module1.py
def greet():
print("Hello from module1!")# main.py
from mypackage import module1
module1.greet()- The
__init__.pyfile can also include initialization code and can controls imports when usingfrom package import *.
# mypackage/__init__.py
from .module1 import greet# now we can directly do
from mypackage import greet
greet()You can import deeply nested modules using dot notation.
from mypackage.subpackage.module import functionOr import the entire package and access modules hierarchically.
import mypackage
mypackage.subpackage.module.function()If you modify a module during runtime, use importlib.reload() to reload it.
import importlib, mymodule
importlib.reload(mymodule)# Absolute path
from mypackage.module1 import greet# Relative import
from .module1 import greet
from ..subpackage.module2 import funcRelative imports only work in packages, not standalone scripts.
You can distribute Python packages using setuptools.
from setuptools import setup, find_packages
setup(
name="mypackage",
version="1.0.0",
packages=find_packages(),
)Build and install
python setup.py sdist bdist_wheel
pip install .Python comes with a rich standard library covering file handling, math, system operations, networking, and more.
| Module | Common Uses |
|---|---|
os |
Interacting with operating system, file paths |
sys |
System-specific parameters and environment |
math |
Mathematical operations |
random |
Random number generation |
datetime |
Working with dates and times |
json |
Parsing and serializing JSON data |
re |
Regular expressions |
collections |
Specialized container datatypes |
itertools |
Efficient looping utilities |
- Python caches imported modules in
sys.modules. Re-importing uses the cache for performance (import caching). - For heavy modules, consider on-demand imports to optimize startup time (Lazy loading). There is even a PEP proposal for dedicated module lazy loading.
- Circular import occur when two modules depend on each other; can be fixed by restructuring or local imports.



