Python Tutorial
Python is an interpreted, high-level, general-purpose programming language. Created by Guido van Rossum and first released in 1991, Python's design philosophy emphasizes code readability with its notable use of significant indentation.
Why Python?
- Readability: Python syntax is designed to be readable and straightforward, looking closely like English mathematics.
- Multi-paradigm: Supports object-oriented, imperative, functional, and procedural programming.
- Extensive Ecosystem: Python has the Python Package Index (PyPI), which hosts hundreds of thousands of third-party modules.
- Interpreted & Dynamic: Python code is executed line-by-line, which makes debugging easier. Types are dynamically resolved at runtime.
Installation & Setup
Before writing any Python code, you must install the Python interpreter, which translates your human-readable code into bytecode that the computer can execute.
1. Installing Python (Windows/Mac/Linux)
Download the latest version of Python from python.org.
Critical Windows Step: When running the Windows installer, you MUST check the box that says "Add Python to PATH". This allows you to run Python from the command prompt globally.
2. The REPL (Read-Eval-Print Loop)
Python comes with an interactive shell called the REPL. Open your terminal or command prompt and type python.
$ python
Python 3.12.0 (tags/v3.12.0:0fb18b0, Oct 2 2023, 13:03:39)
Type "help", "copyright", "credits" or "license" for more information.
>>> print("Hello from the REPL!")
Hello from the REPL!
>>> 2 + 2
4
>>> exit() # To leave the REPL
3. Virtual Environments
For project management, it is best practice to use venv to isolate project dependencies.
# Create a virtual environment named "myenv"
python -m venv myenv
# Activate it (Windows)
myenv\Scripts\activate
# Activate it (Mac/Linux)
source myenv/bin/activate
Basic Syntax & Indentation
Python is famous for its clean, uncluttered syntax. Let's explore the core rules.
1. Significant Whitespace (Indentation)
Where Java or C++ uses curly braces {} to define the scope of loops or functions, Python uses indentation. A block of code must be indented consistently (standard is 4 spaces).
# Correct Indentation
if True:
print("This is indented correctly")
if 5 > 2:
print("This is a nested block")
# Incorrect Indentation (IndentationError)
if True:
print("This will crash the program!")
2. No Semicolons
Python does not require semicolons to terminate statements. A simple newline character is sufficient.
3. Line Continuation
If a line of code is too long, you can use the backslash \ character, or wrap the expression in parentheses.
# Using backslash
total = 1 + 2 + 3 + \
4 + 5 + 6
# Using parentheses (Preferred PEP-8 style)
total = (1 + 2 + 3 +
4 + 5 + 6)
Comments & Docstrings
Documentation is critical for writing maintainable code. Python provides multiple ways to comment.
1. Inline Comments
Created using the hash symbol #. They are ignored entirely by the interpreter.
# Calculate the area of a circle
radius = 5
area = 3.14 * radius ** 2 # The ** operator is used for exponents
2. Docstrings (Documentation Strings)
Docstrings are a special type of multi-line comment used right after the definition of a function, method, class, or module. They are surrounded by triple quotes """ and are stored in the __doc__ attribute of the object.
def multiply(a, b):
"""
Multiply two numbers together and return the result.
Args:
a (int, float): The first number.
b (int, float): The second number.
Returns:
int, float: The product of a and b.
"""
return a * b
# You can access the docstring programmatically!
print(multiply.__doc__)
Variables & Dynamic Typing
Python is dynamically and strongly typed. Variables are simply labels pointing to objects in memory.
Dynamic Typing
You do not declare a variable's type. The type is determined at runtime based on the object assigned to it. Furthermore, a variable can be reassigned to a completely different type later.
x = 100 # x points to an integer object
print(type(x)) # <class 'int'>
x = "Hello" # x now points to a string object
print(type(x)) # <class 'str'>
Multiple Assignment
Python allows you to assign values to multiple variables in one line, which is extremely useful for unpacking data.
# Assigning different values
name, age, is_student = "Alice", 25, True
# Assigning the same value
x = y = z = 0
Memory & The `id()` Function
Variables point to memory addresses. You can use id() to see the memory address.
a = 500
b = 500
print(id(a) == id(b)) # False: They point to different objects in memory
c = a
print(id(a) == id(c)) # True: They point to the SAME object
Numbers & Advanced Math
Python handles numeric types efficiently and natively supports arbitrary-precision integers.
Numeric Types
int: Integers of unlimited size.float: Floating point numbers (double precision).complex: Complex numbers written with a "j" as the imaginary part.
Mathematical Operators
a = 15
b = 4
print(a / b) # True Division: 3.75
print(a // b) # Floor Division (truncates decimal): 3
print(a % b) # Modulo (remainder): 3
print(a ** b) # Exponentiation (15 to the power of 4): 50625
The `math` Module
For advanced mathematics, Python provides a built-in module.
import math
print(math.sqrt(16)) # Square root: 4.0
print(math.ceil(2.1)) # Round up: 3
print(math.floor(2.9)) # Round down: 2
print(math.pi) # Pi constant: 3.141592653589793
Strings & Formatting
Strings are immutable sequences of Unicode characters. Python's string manipulation capabilities are incredibly powerful.
String Slicing
You can extract a substring using the slicing syntax: string[start:stop:step].
text = "Python Programming"
print(text[0:6]) # "Python"
print(text[7:]) # "Programming"
print(text[-1]) # "g" (Negative indexing gets the last character)
print(text[::-1]) # "gnimmargorP nohtyP" (Trick to reverse a string!)
String Formatting (f-strings)
Introduced in Python 3.6, formatted string literals (f-strings) are the fastest and most readable way to inject variables into strings.
name = "Alice"
score = 95.5678
# Using f-string
greeting = f"User {name} achieved a score of {score:.2f}."
print(greeting) # "User Alice achieved a score of 95.57."
# You can even evaluate expressions inside f-strings!
print(f"2 + 2 is {2 + 2}")
Useful String Methods
csv = "apple,banana,cherry"
items = csv.split(",") # Returns a list: ['apple', 'banana', 'cherry']
joined = " | ".join(items) # Returns: "apple | banana | cherry"
print("HELLO".isupper()) # True
print("hello".capitalize()) # "Hello"
Type Casting & Conversion
Sometimes you need to convert a variable from one data type to another. In Python, this is done using constructor functions.
Implicit vs Explicit Conversion
Python automatically converts smaller data types to larger ones (e.g., int to float) when performing math, which is Implicit. Manual casting is Explicit.
# Explicit Casting Examples
num_str = "100"
num_int = int(num_str) # Convert string to integer
pi_int = int(3.1415) # Truncates to 3
bool_val = bool(1) # Converts to True
str_val = str(150.5) # Converts to "150.5"
# The input() function always returns a string, so casting is required:
# age = int(input("Enter your age: "))
# Truthy and Falsy
print(bool("")) # False (Empty strings are falsy)
print(bool(" ")) # True (Contains a space)
print(bool(0)) # False
print(bool([])) # False (Empty lists are falsy)
If...Elif...Else & Ternary Operator
Conditional statements evaluate boolean logic to control program flow.
Standard Flow
score = 85
if score >= 90:
print("Grade: A")
elif score >= 80:
print("Grade: B")
else:
print("Grade: C or below")
Logical Operators: `and`, `or`, `not`
Python uses plain English keywords rather than symbols like && or ||.
age = 25
has_license = True
if age >= 18 and has_license:
print("You can drive.")
if not False:
print("This will execute.")
The Ternary Operator (Conditional Expressions)
You can write an if-else block on a single line for simple variable assignments.
status_code = 200
# Syntax: [value_if_true] if [condition] else [value_if_false]
message = "Success" if status_code == 200 else "Error"
print(message) # "Success"
Match-Case (Structural Pattern Matching)
Introduced in Python 3.10, Structural Pattern Matching is far more powerful than a simple switch statement; it can match structures, types, and unpack data.
Basic Usage
command = "start"
match command:
case "start":
print("Starting engine...")
case "stop":
print("Stopping engine...")
case _:
print("Unknown command") # The underscore acts as the default case
Advanced: Pattern Unpacking
You can match sequences (like lists) and extract variables dynamically!
user_data = ["admin", "john_doe", "john@example.com"]
match user_data:
case ["admin", username, email]:
print(f"Admin detected! Welcome {username}, sending email to {email}.")
case ["guest", username]:
print(f"Welcome guest {username}.")
case _:
print("Invalid user structure.")
Loops (For, While) & The Enumerate Function
Loops allow you to iterate over sequences. Python's for loop is essentially a "for-each" loop from other languages.
The `while` Loop
Executes as long as a condition remains true.
count = 3
while count > 0:
print(f"Countdown: {count}")
count -= 1
The `for` Loop
Iterates over items of any sequence (a list, string, dictionary, etc.).
# Iterating over a string
for char in "Python":
print(char, end="-") # Output: P-y-t-h-o-n-
# Using range(start, stop, step)
for i in range(0, 10, 2):
print(i) # Output: 0, 2, 4, 6, 8
The `enumerate()` Function
Often, you need both the item and its index. enumerate() is the Pythonic way to do this.
fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
print(f"Index {index} is {fruit}")
Break, Continue, and `for-else`
Control loop execution flow using these keywords.
`break` and `continue`
for num in range(10):
if num % 2 == 0:
continue # Skip even numbers
if num == 7:
break # Stop the loop completely at 7
print(num) # Output: 1, 3, 5
The Unique `for...else` Construct
Python has a unique feature where you can attach an else block to a loop. The else block executes ONLY IF the loop finishes naturally (i.e., it was NOT terminated by a break statement).
search_target = "Zebra"
animals = ["Cat", "Dog", "Bird"]
for animal in animals:
if animal == search_target:
print("Target found!")
break
else:
# This executes because the loop finished without hitting 'break'
print("Target was never found in the list.")
Lists in Depth
Lists are dynamic arrays. They are mutable, ordered, and can contain mixed data types.
Advanced List Operations
matrix = [
[1, 2, 3],
[4, 5, 6]
]
print(matrix[1][2]) # Output: 6
numbers = [10, 20, 30]
numbers.extend([40, 50]) # Adds elements from another iterable
numbers.insert(1, 15) # Inserts 15 at index 1
popped_item = numbers.pop() # Removes and returns the last item
List Comprehensions
A concise way to create lists. Syntax: [expression for item in iterable if condition]
squares = [x**2 for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
evens = [x for x in range(20) if x % 2 == 0]
Custom Sorting
You can sort lists of complex objects using the key parameter.
words = ["banana", "pie", "Washington", "book"]
# Sort by length of the string
words.sort(key=len)
print(words) # ['pie', 'book', 'banana', 'Washington']
Tuples (Immutable Sequences)
Tuples are identical to lists, but they cannot be changed after creation (immutable). This makes them faster and memory efficient.
Creating and Packing
# Parentheses are optional, the comma makes the tuple!
point = 10, 20
singleton = (5,) # Note the trailing comma for a single-element tuple
print(type(point)) # <class 'tuple'>
Tuple Unpacking
You can extract variables directly from a tuple. You can also use the * operator to grab remaining elements.
record = ("Alice", 25, "Engineer", "New York", "USA")
name, age, *rest_of_data = record
print(name) # "Alice"
print(rest_of_data) # ["Engineer", "New York", "USA"] (Notice it packs into a list)
Dictionaries in Depth
Dictionaries (Hash Maps) store data in key-value pairs. As of Python 3.7, they maintain insertion order.
Safe Access and Default Values
user = {"username": "admin", "id": 101}
# print(user["email"]) # KeyError!
print(user.get("email")) # Returns None, prevents crash
print(user.get("email", "Unknown")) # Returns "Unknown"
# setdefault adds the key if it doesn't exist
user.setdefault("role", "guest")
Dictionary Comprehensions
Like list comprehensions, but for dictionaries.
squares_dict = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
Merging Dictionaries (Python 3.9+)
Use the union operator | to merge dictionaries effortlessly.
dict1 = {"a": 1, "b": 2}
dict2 = {"b": 3, "c": 4}
merged = dict1 | dict2
print(merged) # {'a': 1, 'b': 3, 'c': 4} (dict2 overwrites overlapping keys)
Sets & Mathematical Operations
Sets are collections of unique elements. They are highly optimized for checking membership (O(1) time complexity).
Removing Duplicates
The fastest way to remove duplicates from a list is to cast it to a set.
raw_data = [1, 2, 2, 3, 3, 3, 4]
clean_data = list(set(raw_data))
# [1, 2, 3, 4]
Set Operations
Sets support mathematical operations like Union, Intersection, and Difference.
engineers = {"Alice", "Bob", "Charlie"}
managers = {"Charlie", "David"}
# Union (All unique people)
print(engineers | managers) # {'Bob', 'Alice', 'David', 'Charlie'}
# Intersection (Who is BOTH an engineer and a manager?)
print(engineers & managers) # {'Charlie'}
# Difference (Engineers who are NOT managers)
print(engineers - managers) # {'Bob', 'Alice'}
# Symmetric Difference (People who are one or the other, but NOT both)
print(engineers ^ managers) # {'Bob', 'Alice', 'David'}
Functions: *args and **kwargs
Functions are first-class citizens in Python. You can pass them as arguments, return them from other functions, and assign them to variables.
Default Arguments & Type Hinting
# Python 3.5+ introduced Type Hinting for better readability
def greet(name: str, times: int = 1) -> None:
for _ in range(times):
print(f"Hello, {name}!")
greet("Alice", 2)
Arbitrary Arguments: `*args`
Use *args when you do not know how many positional arguments will be passed. It packs them into a tuple.
def sum_all(*args):
total = 0
for num in args:
total += num
return total
print(sum_all(1, 2, 3, 4, 5)) # 15
Arbitrary Keyword Arguments: `**kwargs`
Use **kwargs to handle an arbitrary number of named/keyword arguments. It packs them into a dictionary.
def build_profile(first_name, last_name, **kwargs):
profile = {"first": first_name, "last": last_name}
profile.update(kwargs) # Merges the kwargs dictionary
return profile
user = build_profile("Albert", "Einstein", field="Physics", nobel_prize=True)
print(user) # {'first': 'Albert', 'last': 'Einstein', 'field': 'Physics', 'nobel_prize': True}
Lambda Functions & Higher-Order Functions
Lambda functions are anonymous, inline functions. They are most powerful when combined with functions that take other functions as arguments (Higher-Order Functions).
Map, Filter, and Reduce
map(): Applies a function to all items in an input list.filter(): Creates a list of elements for which a function returns True.reduce(): Applies a rolling computation to sequential pairs of values in a list.
numbers = [1, 2, 3, 4, 5]
# map: Square every number
squared = list(map(lambda x: x**2, numbers))
# [1, 4, 9, 16, 25]
# filter: Keep only even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
# [2, 4]
# reduce (requires import): Multiply all numbers together
from functools import reduce
product = reduce(lambda x, y: x * y, numbers)
# 120 (1 * 2 * 3 * 4 * 5)
Scope Rules (LEGB) & Closures
Variable scope in Python follows the LEGB rule: Local, Enclosing, Global, Built-in.
Global and Nonlocal Keywords
counter = 0
def increment():
global counter # Declares we are modifying the global 'counter'
counter += 1
increment()
print(counter) # 1
Closures
A closure occurs when a nested function captures and remembers the variables from its enclosing scope, even after the outer function has finished executing.
def multiplier_factory(factor):
# This inner function remembers 'factor'
def inner_multiplier(number):
return number * factor
return inner_multiplier
# Generate specific multiplier functions
double = multiplier_factory(2)
triple = multiplier_factory(3)
print(double(5)) # 10
print(triple(5)) # 15
Modules & The `__name__` Variable
Python files are called modules. You can organize code logically by grouping related functions/classes into files.
Importing Strategies
import sys # Imports the whole module
from math import pi, sqrt # Imports specific functions/variables
import numpy as np # Imports and aliases a module
The `if __name__ == '__main__':` Guard
When the Python interpreter reads a source file, it sets the special __name__ variable. If the file is being run directly, __name__ is set to "__main__". If the file is imported into another script, __name__ is set to the module's name.
def run_tests():
print("Testing functionality...")
# This code block only executes if you run THIS script directly.
# It will NOT execute if another script imports this file.
if __name__ == '__main__':
print("Script started directly.")
run_tests()
Classes, Objects, and Magic (Dunder) Methods
Everything in Python is an object. Classes are blueprints for creating objects.
The Blueprint & Constructor
The __init__ method is the constructor. The self parameter is a reference to the current instance of the class.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def read(self):
print(f"Reading {self.title}...")
Magic (Dunder) Methods
Methods with double underscores (e.g., __str__, __add__) allow you to override standard Python operators and behaviors for your custom classes!
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# Defines what is printed when you print(object)
def __str__(self):
return f"Vector({self.x}, {self.y})"
# Allows the use of the '+' operator between Vector objects!
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
v1 = Vector(2, 4)
v2 = Vector(3, 1)
print(v1) # Output: Vector(2, 4)
print(v1 + v2) # Output: Vector(5, 5)
Inheritance & MRO
Inheritance promotes code reuse. Python supports both single and multiple inheritance.
Using `super()`
The super() function gives you access to methods in a superclass from the subclass. It is mostly used to extend the functionality of the parent's constructor.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Developer(Employee):
def __init__(self, name, salary, language):
# Call the parent constructor
super().__init__(name, salary)
# Add new property
self.language = language
dev = Developer("Alice", 90000, "Python")
Multiple Inheritance & MRO
If a class inherits from multiple parents, Python uses the Method Resolution Order (MRO) to determine which parent's method to call first.
class A: pass
class B: pass
class C(A, B): pass
# You can view the order Python searches for methods:
print(C.mro())
# [<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]
Polymorphism & Class Methods
Polymorphism allows methods to do different things based on the object it is acting upon.
Duck Typing
"If it walks like a duck and quacks like a duck, it must be a duck." Python doesn't care about the object's strict class, only that it has the required methods.
class AudioPlayer:
def play(self): print("Playing mp3...")
class VideoPlayer:
def play(self): print("Playing mp4...")
def start_media(device):
# Python doesn't care if 'device' is an Audio or Video player,
# as long as it has a play() method!
device.play()
start_media(AudioPlayer())
start_media(VideoPlayer())
`@classmethod` and `@staticmethod`
class MathUtils:
@staticmethod
def add(x, y):
# A utility function that doesn't need 'self'
return x + y
class Date:
def __init__(self, year, month, day):
self.year, self.month, self.day = year, month, day
@classmethod
def from_string(cls, date_str):
# A class method takes 'cls' instead of 'self'
# Often used as alternate constructors
year, month, day = map(int, date_str.split('-'))
return cls(year, month, day)
today = Date.from_string("2025-10-25")
Encapsulation & Properties
Python uses underscores to indicate access restrictions, and the `@property` decorator to build getters and setters.
Name Mangling (Private Variables)
class Vault:
def __init__(self):
self._protected = "Please don't touch"
self.__private = "Super Secret" # Name mangled
v = Vault()
print(v._protected) # Accessible, but bad practice
# print(v.__private) # AttributeError!
print(v._Vault__private) # How it's internally renamed (mangled)
The `@property` Decorator
Allows you to define methods that can be accessed like attributes, giving you control over getting and setting data.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
# Acts like a getter
return (self._celsius * 9/5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
# Validates and sets data
if value < -459.67:
raise ValueError("Temperature below absolute zero!")
self._celsius = (value - 32) * 5/9
t = Temperature(0)
print(t.fahrenheit) # 32.0 (Accessed like a property, not a method!)
t.fahrenheit = 212 # Calls the setter
print(t._celsius) # 100.0
Exception Handling & Custom Exceptions
Robust programs anticipate failures. Python uses try/except blocks to gracefully handle runtime errors instead of crashing.
The `try-except-else-finally` Block
try: Code that might fail.except: Code that runs if an exception occurs.else: Code that runs only if NO exception occurs.finally: Code that runs always (used for cleanup).
try:
result = 10 / 2
except ZeroDivisionError:
print("Cannot divide by zero!")
except Exception as e:
print(f"Generic error caught: {e}")
else:
print(f"Success! Result is {result}")
finally:
print("Execution complete.")
Raising Custom Exceptions
You can create your own exception hierarchy by inheriting from the base Exception class.
class InvalidAgeError(Exception):
"""Raised when an age is illogical."""
pass
def register_user(age):
if age < 0 or age > 150:
raise InvalidAgeError(f"Age {age} is invalid.")
print("Registered.")
try:
register_user(200)
except InvalidAgeError as e:
print(e) # Output: Age 200 is invalid.
File Handling & Context Managers
Python handles file I/O safely via Context Managers (the with statement), ensuring resources are released.
Reading and Writing Files
# Writing to a file (overwrites existing content)
with open('data.txt', 'w') as file:
file.write("Line 1\n")
file.write("Line 2\n")
# Reading from a file line-by-line
with open('data.txt', 'r') as file:
for line in file:
print(line.strip()) # strip() removes the \n
Working with JSON
JSON is the standard for web data. Python's built-in json module makes serialization easy.
import json
user = {"name": "Alice", "skills": ["Python", "AWS"]}
# Write dictionary to a JSON file
with open('user.json', 'w') as f:
json.dump(user, f, indent=4)
# Read JSON file back into a dictionary
with open('user.json', 'r') as f:
data = json.load(f)
print(data["skills"]) # ['Python', 'AWS']
Custom Context Managers
You can make your own objects compatible with the with statement using __enter__ and __exit__.
class Timer:
import time
def __enter__(self):
self.start = self.time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.end = self.time.time()
print(f"Elapsed: {self.end - self.start:.4f} seconds")
# Usage:
with Timer():
sum([x**2 for x in range(1000000)])
Iterators, Generators & `yield`
Generators allow you to lazily produce a sequence of values over time, rather than computing them all at once and storing them in memory.
The `yield` Keyword
When a function contains yield, it becomes a generator. Instead of returning a value and destroying local variables, it yields a value and pauses execution, saving its state for the next call.
def countdown(num):
print("Starting countdown...")
while num > 0:
yield num # Pauses here, returns num
num -= 1
print("Blastoff!")
# The function does not run until iterated!
for n in countdown(3):
print(n)
# Output:
# Starting countdown...
# 3
# 2
# 1
# Blastoff!
Generator Expressions
Similar to list comprehensions, but they use parentheses () and are incredibly memory efficient because they compute values on-the-fly.
import sys
# List Comprehension: Computes all 1 million numbers and stores in RAM
list_comp = [x**2 for x in range(1000000)]
print(sys.getsizeof(list_comp)) # ~8,000,000 bytes
# Generator Expression: Stores only the formula, not the data
gen_expr = (x**2 for x in range(1000000))
print(sys.getsizeof(gen_expr)) # ~104 bytes!!!
# To use it:
# print(next(gen_expr)) # 0
# print(next(gen_expr)) # 1
Advanced Decorators
Decorators dynamically alter the functionality of a function, method, or class without having to directly use subclasses or change the source code of the function being decorated.
Creating a Basic Decorator
A decorator is a function that takes another function and extends its behavior.
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__}")
result = func(*args, **kwargs)
print(f"Function {func.__name__} finished executing.")
return result
return wrapper
@logger
def add(x, y):
return x + y
add(5, 7)
Decorators with Arguments
If you want to pass arguments to a decorator, you need three levels of nested functions!
def repeat(num_times):
def decorator_repeat(func):
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator_repeat
@repeat(num_times=3)
def greet(name):
print(f"Hello {name}")
greet("World")
# Output:
# Hello World
# Hello World
# Hello World