Python Interview Questions - Frequently Asked

Comprehensive guide to Python interview questions covering core concepts, data structures, OOP, functional programming, and best practices with detailed explanations for 2025.

21 questions
PythonInterviewProgrammingOOPData StructuresCodingSoftware Development

Questions & Answers

1What are mutable and immutable types in Python?

Mutable types are objects whose content can be changed after creation without creating a new object. Examples include lists, dictionaries, and sets.

Immutable types cannot be modified after creation. Any modification creates a new object. Examples include integers, floats, strings, tuples, and frozensets.

Example:

# Mutable - list my_list = [1, 2, 3] my_list[0] = 10 # Original list is modified print(my_list) # [10, 2, 3] # Immutable - tuple my_tuple = (1, 2, 3) # my_tuple[0] = 10 # This would raise TypeError

Understanding mutability is crucial for avoiding bugs, especially when passing objects to functions or using them as dictionary keys.

2How is a list different from a tuple?

Lists and tuples differ in several key ways:

Mutability:

  • Lists are mutable - you can add, remove, or modify elements
  • Tuples are immutable - once created, they cannot be changed

Syntax:

  • Lists use square brackets: [1, 2, 3]
  • Tuples use parentheses: (1, 2, 3)

Performance:

  • Tuples are slightly faster and use less memory
  • Lists have more overhead due to mutability

Use Cases:

  • Use lists for collections that need to change (shopping cart items, user inputs)
  • Use tuples for fixed data (coordinates, RGB colors, database records)

Example:

my_list = [1, 2, 3] my_list.append(4) # Works fine my_tuple = (1, 2, 3) # my_tuple.append(4) # AttributeError

3What is the difference between shallow copy and deep copy in Python?

When copying objects in Python, understanding shallow vs deep copy is essential:

Shallow Copy:

  • Creates a new object but references the same nested objects
  • Changes to nested objects affect both copies
  • Created using copy.copy() or list.copy()

Deep Copy:

  • Creates a completely independent copy, including all nested objects
  • Changes to nested objects don't affect the original
  • Created using copy.deepcopy()

Example:

import copy original = [[1, 2], [3, 4]] # Shallow copy shallow = copy.copy(original) shallow[0][0] = 99 print(original) # [[99, 2], [3, 4]] - Original affected! # Deep copy original = [[1, 2], [3, 4]] deep = copy.deepcopy(original) deep[0][0] = 99 print(original) # [[1, 2], [3, 4]] - Original unchanged

Use shallow copy for simple objects and deep copy when dealing with nested structures.

4What is the use of *args and **kwargs in a Python function?

These special parameters allow functions to accept variable numbers of arguments:

*args (Non-Keyword Arguments):

  • Allows a function to accept any number of positional arguments
  • Arguments are received as a tuple

**kwargs (Keyword Arguments):

  • Allows a function to accept any number of keyword arguments
  • Arguments are received as a dictionary

Examples:

# Using *args def sum_all(*args): return sum(args) print(sum_all(1, 2, 3, 4)) # 10 # Using **kwargs def print_info(**kwargs): for key, value in kwargs.items(): print(f"{key}: {value}") print_info(name="Alice", age=25, city="NYC") # Using both def full_function(*args, **kwargs): print("Positional:", args) print("Keyword:", kwargs) full_function(1, 2, name="Bob", age=30) # Positional: (1, 2) # Keyword: {'name': 'Bob', 'age': 30}

These are extremely useful for creating flexible APIs and wrapper functions.

5How is a lambda function different from a normal function defined with def?

Lambda functions are anonymous, single-expression functions with key differences from regular functions:

Lambda Functions:

  • Anonymous (no name required)
  • Single expression only
  • Automatically returns the result
  • Typically used for short, simple operations
  • Cannot contain statements (no assignments, loops, etc.)

Regular Functions (def):

  • Named and reusable
  • Can contain multiple statements and expressions
  • Requires explicit return statement
  • Can include documentation strings
  • Better for complex logic

Examples:

# Lambda function square = lambda x: x ** 2 print(square(5)) # 25 # Equivalent regular function def square_func(x): return x ** 2 # Common use case - with sorted() students = [('Alice', 85), ('Bob', 92), ('Charlie', 78)] sorted_students = sorted(students, key=lambda x: x[1]) print(sorted_students) # Sorted by score

Use lambda for simple operations in places like map(), filter(), and sorted(). Use regular functions for anything more complex.

6What are list comprehensions, and why are they used?

List comprehensions provide a concise way to create lists based on existing iterables.

Benefits:

  • More readable and Pythonic than traditional loops
  • Often faster than equivalent for-loops
  • Can include conditional logic
  • Reduce code verbosity

Basic Syntax:

[expression for item in iterable if condition]

Examples:

# Traditional approach squares = [] for x in range(10): squares.append(x ** 2) # List comprehension - cleaner! squares = [x ** 2 for x in range(10)] # With condition even_squares = [x ** 2 for x in range(10) if x % 2 == 0] print(even_squares) # [0, 4, 16, 36, 64] # Nested comprehension matrix = [[1, 2], [3, 4], [5, 6]] flattened = [num for row in matrix for num in row] print(flattened) # [1, 2, 3, 4, 5, 6] # String manipulation words = ["hello", "world", "python"] capitalized = [word.upper() for word in words] print(capitalized) # ['HELLO', 'WORLD', 'PYTHON']

Note: Don't overuse them - if the logic becomes too complex, use a regular loop for better readability.

7What is the difference between the is operator and the == operator?

These operators serve different purposes in Python:

== Operator (Equality):

  • Compares the values of two objects
  • Returns True if values are equal
  • Can be overridden using __eq__() method

is Operator (Identity):

  • Compares the memory addresses (identity) of two objects
  • Returns True if both variables point to the same object
  • Cannot be overridden

Examples:

# Value comparison with == a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True - same values print(a is b) # False - different objects in memory # Identity comparison with is c = a print(a is c) # True - same object print(a == c) # True - same values # Special case with small integers (Python caches them) x = 256 y = 256 print(x is y) # True - Python caches small integers z = 257 w = 257 print(z is w) # False - larger integers aren't cached # Best practice for None value = None if value is None: # Correct way print("Value is None")

Rule of thumb: Use == for value comparison and is only when checking for None or comparing singleton objects.

8Explain the role of __init__() in a Python class.

The __init__() method is a special constructor method in Python classes that initializes object attributes.

Key Points:

  • Automatically called when a new object is created
  • Used to set initial values for object attributes
  • The first parameter is always self (reference to the instance)
  • Can accept additional parameters for initialization
  • Not required if no initialization is needed

Example:

class Person: def __init__(self, name, age): self.name = name # Instance attribute self.age = age self.email = None # Default value def introduce(self): return f"Hi, I'm {self.name}, {self.age} years old" # Creating objects person1 = Person("Alice", 30) # __init__ is called automatically person2 = Person("Bob", 25) print(person1.introduce()) # Hi, I'm Alice, 30 years old print(person2.name) # Bob

With Default Parameters:

class Student: def __init__(self, name, grade="A"): self.name = name self.grade = grade student1 = Student("Charlie") # Uses default grade student2 = Student("Diana", "B+") # Custom grade

Note: __init__() is different from __new__(). The __new__() method creates the object, while __init__() initializes it.

9What are "dunder" (magic) methods? Give some examples.

Dunder methods (double underscore methods) are special methods in Python that enable operator overloading and customization of built-in behavior.

Common Dunder Methods:

Object Representation:

  • __init__() - Constructor
  • __str__() - String representation for users
  • __repr__() - Official representation for developers

Comparison Operators:

  • __eq__() - Equality (==)
  • __lt__() - Less than (<)
  • __gt__() - Greater than (>)

Arithmetic Operators:

  • __add__() - Addition (+)
  • __sub__() - Subtraction (-)
  • __mul__() - Multiplication (*)

Container Methods:

  • __len__() - Length
  • __getitem__() - Indexing
  • __setitem__() - Assignment

Example:

class Book: def __init__(self, title, pages): self.title = title self.pages = pages def __str__(self): return f"{self.title} ({self.pages} pages)" def __repr__(self): return f"Book('{self.title}', {self.pages})" def __len__(self): return self.pages def __add__(self, other): return self.pages + other.pages def __eq__(self, other): return self.pages == other.pages book1 = Book("Python Guide", 350) book2 = Book("Data Science", 350) print(str(book1)) # Python Guide (350 pages) print(repr(book1)) # Book('Python Guide', 350) print(len(book1)) # 350 print(book1 + book2) # 700 print(book1 == book2) # True

Dunder methods make your custom classes behave like built-in Python types.

10What is the difference between @staticmethod, @classmethod, and an instance method?

These three method types serve different purposes in Python classes:

Instance Methods:

  • Default method type
  • First parameter is self (instance reference)
  • Can access and modify instance attributes
  • Can access class attributes through self

@classmethod:

  • First parameter is cls (class reference)
  • Can access and modify class attributes
  • Cannot access instance attributes
  • Often used for alternative constructors

@staticmethod:

  • No automatic first parameter
  • Cannot access instance or class attributes directly
  • Behaves like a regular function but belongs to the class namespace
  • Used for utility functions related to the class

Example:

class Pizza: base_price = 10 # Class attribute def __init__(self, size, toppings): self.size = size # Instance attribute self.toppings = toppings # Instance method def calculate_price(self): return self.base_price + len(self.toppings) * 2 # Class method - alternative constructor @classmethod def margherita(cls, size): return cls(size, ['cheese', 'tomato']) # Static method - utility function @staticmethod def is_valid_size(size): return size in ['small', 'medium', 'large'] # Using instance method pizza1 = Pizza('large', ['pepperoni', 'mushrooms']) print(pizza1.calculate_price()) # 14 # Using class method pizza2 = Pizza.margherita('medium') print(pizza2.toppings) # ['cheese', 'tomato'] # Using static method print(Pizza.is_valid_size('medium')) # True print(Pizza.is_valid_size('tiny')) # False

11What are iterators and generators in Python? How do they differ?

Both iterators and generators provide ways to iterate over data, but they work differently:

Iterators:

  • Objects that implement __iter__() and __next__() methods
  • Must explicitly maintain state
  • Raise StopIteration when exhausted
  • Can be created from any iterable using iter()

Generators:

  • Special functions that use yield instead of return
  • Automatically maintain state between calls
  • More memory-efficient (lazy evaluation)
  • Simpler to write than iterators
  • Can only be iterated once

Examples:

# Iterator example class CountUp: def __init__(self, max_num): self.max_num = max_num self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.max_num: raise StopIteration self.current += 1 return self.current counter = CountUp(3) for num in counter: print(num) # 1, 2, 3 # Generator example - much simpler! def count_up(max_num): current = 1 while current <= max_num: yield current current += 1 for num in count_up(3): print(num) # 1, 2, 3 # Generator expression (like list comprehension) squares_gen = (x ** 2 for x in range(5)) print(list(squares_gen)) # [0, 1, 4, 9, 16] # Memory efficiency demonstration import sys list_comp = [x for x in range(1000000)] # Creates entire list in memory gen_exp = (x for x in range(1000000)) # Generates values on demand print(sys.getsizeof(list_comp)) # ~8 MB print(sys.getsizeof(gen_exp)) # ~128 bytes

Use generators when working with large datasets or infinite sequences to save memory.

12What is the use of decorators in Python? Provide a simple example.

Decorators are functions that modify the behavior of other functions or classes without changing their source code.

Key Concepts:

  • Functions are first-class objects in Python
  • Decorators wrap another function to extend its behavior
  • Denoted by @decorator_name syntax
  • Commonly used for logging, authentication, timing, caching

Simple Example:

# Basic decorator def uppercase_decorator(func): def wrapper(): result = func() return result.upper() return wrapper @uppercase_decorator def greet(): return "hello world" print(greet()) # HELLO WORLD # Decorator with arguments def repeat(times): def decorator(func): def wrapper(*args, **kwargs): for _ in range(times): func(*args, **kwargs) return wrapper return decorator @repeat(3) def say_hello(name): print(f"Hello, {name}!") say_hello("Alice") # Output: # Hello, Alice! # Hello, Alice! # Hello, Alice!

Practical Example - Timing Functions:

import time def timer_decorator(func): def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) end_time = time.time() print(f"{func.__name__} took {end_time - start_time:.4f} seconds") return result return wrapper @timer_decorator def slow_function(): time.sleep(2) return "Done" slow_function() # Output: slow_function took 2.0012 seconds

Built-in Decorators:

  • @property - Converts method to attribute
  • @staticmethod - Defines static method
  • @classmethod - Defines class method

13How does Python handle memory management and garbage collection?

Python uses automatic memory management with several key mechanisms:

1. Private Heap Space:

  • All Python objects are stored in a private heap
  • Managed internally by Python Memory Manager
  • Not directly accessible to programmers

2. Reference Counting:

  • Each object has a reference count
  • Count increases when new reference is created
  • Count decreases when reference is deleted or goes out of scope
  • Object is deallocated when count reaches zero

3. Garbage Collection:

  • Handles circular references that reference counting can't
  • Uses generational garbage collection
  • Objects are divided into three generations (0, 1, 2)
  • Younger generations are collected more frequently

Example:

import sys import gc # Reference counting a = [1, 2, 3] print(sys.getrefcount(a)) # Shows reference count b = a # Reference count increases print(sys.getrefcount(a)) del b # Reference count decreases print(sys.getrefcount(a)) # Circular reference problem class Node: def __init__(self): self.ref = None node1 = Node() node2 = Node() node1.ref = node2 node2.ref = node1 # Circular reference # Manually trigger garbage collection gc.collect() # Cleans up circular references # Check garbage collector settings print(gc.get_threshold()) # (700, 10, 10) - collection thresholds print(gc.isenabled()) # True # Disable/enable garbage collection gc.disable() gc.enable()

Best Practices:

  • Don't create unnecessary circular references
  • Use context managers (with statement) for resource management
  • Let Python handle memory automatically in most cases
  • Use __del__() method carefully (it's called on garbage collection)

14Explain exception handling in Python — how do try, except, else, and finally work?

Python's exception handling provides a robust way to handle errors gracefully:

Components:

  • try - Code that might raise an exception
  • except - Handles specific exceptions
  • else - Executes if no exception occurs
  • finally - Always executes (cleanup code)

Basic Syntax:

try: # Code that might raise exception result = 10 / 0 except ZeroDivisionError: # Handle specific exception print("Cannot divide by zero!") except Exception as e: # Catch all other exceptions print(f"Error occurred: {e}") else: # Executes if no exception print("Division successful") finally: # Always executes print("Cleanup complete")

Practical Examples:

# Multiple exception types def divide_numbers(a, b): try: result = a / b return result except ZeroDivisionError: return "Cannot divide by zero" except TypeError: return "Invalid input types" except Exception as e: return f"Unexpected error: {e}" finally: print("Division attempt completed") print(divide_numbers(10, 2)) # 5.0 print(divide_numbers(10, 0)) # Cannot divide by zero print(divide_numbers(10, "2")) # Invalid input types # File handling with exception handling def read_file(filename): try: with open(filename, 'r') as file: content = file.read() return content except FileNotFoundError: return "File not found" except PermissionError: return "Permission denied" finally: print("File operation completed") # Raising custom exceptions class InvalidAgeError(Exception): pass def check_age(age): if age < 0: raise InvalidAgeError("Age cannot be negative") elif age < 18: raise ValueError("Must be 18 or older") return "Valid age" try: check_age(-5) except InvalidAgeError as e: print(f"Custom error: {e}") except ValueError as e: print(f"Value error: {e}")

Best Practices:

  • Catch specific exceptions, not bare except
  • Use finally for cleanup (closing files, connections)
  • Don't use exceptions for normal flow control
  • Log exceptions for debugging

15What is the purpose of the statement if __name__ == "__main__": in a Python script?

This statement checks whether a Python file is being run directly or being imported as a module.

How it Works:

  • When a file is run directly, Python sets __name__ to "__main__"
  • When a file is imported, __name__ is set to the module name
  • Allows you to have code that only runs when the script is executed directly

Use Cases:

  • Testing code in the same file as module definitions
  • Providing a CLI interface for modules
  • Running demonstrations or examples
  • Preventing code execution when importing

Example:

# mymodule.py def add(a, b): """Function to add two numbers""" return a + b def multiply(a, b): """Function to multiply two numbers""" return a * b # This code only runs when script is executed directly if __name__ == "__main__": # Testing/demo code print("Testing the module...") print(f"2 + 3 = {add(2, 3)}") print(f"2 * 3 = {multiply(2, 3)}") print("Module works correctly!")

When Used:

# Scenario 1: Running directly $ python mymodule.py # Output: # Testing the module... # 2 + 3 = 5 # 2 * 3 = 6 # Module works correctly! # Scenario 2: Importing in another file # main.py import mymodule result = mymodule.add(10, 20) print(result) # 30 # The test code in mymodule.py doesn't run

Practical Pattern:

def main(): """Main function containing script logic""" print("Running main program") # Your code here if __name__ == "__main__": main()

This pattern makes your code reusable as both a standalone script and an importable module.

16What are map(), filter(), and reduce() functions in Python and when would you use them?

These are functional programming tools that operate on iterables without explicit loops.

map():

  • Applies a function to every item in an iterable
  • Returns a map object (iterator)
  • Syntax: map(function, iterable)

filter():

  • Filters items based on a condition (function returns True/False)
  • Returns a filter object (iterator)
  • Syntax: filter(function, iterable)

reduce():

  • Applies a function cumulatively to reduce iterable to a single value
  • Must import from functools module
  • Syntax: reduce(function, iterable[, initializer])

Examples:

from functools import reduce # map() - Transform each element numbers = [1, 2, 3, 4, 5] squared = list(map(lambda x: x ** 2, numbers)) print(squared) # [1, 4, 9, 16, 25] # With regular function def double(x): return x * 2 doubled = list(map(double, numbers)) print(doubled) # [2, 4, 6, 8, 10] # Multiple iterables list1 = [1, 2, 3] list2 = [4, 5, 6] sums = list(map(lambda x, y: x + y, list1, list2)) print(sums) # [5, 7, 9] # filter() - Select elements based on condition numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] even_numbers = list(filter(lambda x: x % 2 == 0, numbers)) print(even_numbers) # [2, 4, 6, 8, 10] # Filter strings by length words = ["hi", "hello", "hey", "wonderful", "world"] long_words = list(filter(lambda w: len(w) > 3, words)) print(long_words) # ['hello', 'wonderful', 'world'] # reduce() - Cumulative operation numbers = [1, 2, 3, 4, 5] sum_all = reduce(lambda x, y: x + y, numbers) print(sum_all) # 15 (1+2+3+4+5) product = reduce(lambda x, y: x * y, numbers) print(product) # 120 (1*2*3*4*5) # Find maximum max_value = reduce(lambda x, y: x if x > y else y, numbers) print(max_value) # 5 # With initial value sum_with_initial = reduce(lambda x, y: x + y, numbers, 10) print(sum_with_initial) # 25 (10+1+2+3+4+5)

Comparison with List Comprehensions:

# These are equivalent # Using map squared = list(map(lambda x: x ** 2, numbers)) # Using list comprehension (often more Pythonic) squared = [x ** 2 for x in numbers] # Using filter even = list(filter(lambda x: x % 2 == 0, numbers)) # Using list comprehension even = [x for x in numbers if x % 2 == 0]

When to use:

  • Use map() and filter() with existing functions
  • Use list comprehensions for lambda-based transformations (more Pythonic)
  • Use reduce() for cumulative operations where built-in alternatives don't exist

17How are sets different from lists and dictionaries in Python?

Sets, lists, and dictionaries are all collection types but with distinct characteristics:

Lists:

  • Ordered collection
  • Allows duplicates
  • Mutable (can be modified)
  • Indexed by position
  • Syntax: [1, 2, 3]

Sets:

  • Unordered collection
  • No duplicates allowed
  • Mutable (elements must be immutable)
  • No indexing
  • Optimized for membership testing
  • Syntax: {1, 2, 3}

Dictionaries:

  • Ordered (Python 3.7+) key-value pairs
  • Keys must be unique and immutable
  • Values can be duplicates
  • Indexed by keys
  • Syntax: {'a': 1, 'b': 2}

Examples:

# Lists - ordered, allows duplicates my_list = [1, 2, 2, 3, 4, 4] print(my_list[0]) # 1 (indexed access) print(len(my_list)) # 6 # Sets - unordered, no duplicates my_set = {1, 2, 2, 3, 4, 4} print(my_set) # {1, 2, 3, 4} - duplicates removed # print(my_set[0]) # TypeError - sets don't support indexing # Dictionaries - key-value pairs my_dict = {'name': 'Alice', 'age': 25, 'age': 30} print(my_dict) # {'name': 'Alice', 'age': 30} - duplicate key overwritten print(my_dict['name']) # Alice # Set operations - very useful! set1 = {1, 2, 3, 4} set2 = {3, 4, 5, 6} print(set1 | set2) # Union: {1, 2, 3, 4, 5, 6} print(set1 & set2) # Intersection: {3, 4} print(set1 - set2) # Difference: {1, 2} print(set1 ^ set2) # Symmetric difference: {1, 2, 5, 6} # Membership testing - sets are fastest import time large_list = list(range(10000)) large_set = set(range(10000)) # List membership (slow) start = time.time() 9999 in large_list print(f"List: {time.time() - start:.6f}s") # Set membership (fast) start = time.time() 9999 in large_set print(f"Set: {time.time() - start:.6f}s") # Removing duplicates from a list list_with_dupes = [1, 2, 2, 3, 3, 3, 4] unique_list = list(set(list_with_dupes)) print(unique_list) # [1, 2, 3, 4]

When to use:

  • List: Ordered collection, need indexing, allow duplicates
  • Set: Remove duplicates, membership testing, set operations
  • Dictionary: Key-value mapping, fast lookups by key

18What is the difference between inheritance and polymorphism in Python OOP?

Inheritance and polymorphism are fundamental OOP concepts that work together but serve different purposes:

Inheritance:

  • Mechanism where a class derives properties and methods from another class
  • Enables code reusability
  • Creates a parent-child (is-a) relationship
  • Child class inherits all attributes and methods from parent

Polymorphism:

  • Ability of different objects to respond to the same method in different ways
  • Greek for "many forms"
  • Achieved through method overriding or duck typing
  • Enables writing flexible, reusable code

Inheritance Example:

# Parent class (Base class) class Animal: def __init__(self, name): self.name = name def speak(self): return "Some sound" def info(self): return f"I am {self.name}" # Child classes (Derived classes) class Dog(Animal): # Dog inherits from Animal def speak(self): # Method overriding return "Woof!" def fetch(self): # Additional method return f"{self.name} is fetching" class Cat(Animal): # Cat inherits from Animal def speak(self): # Method overriding return "Meow!" def scratch(self): # Additional method return f"{self.name} is scratching" # Using inheritance dog = Dog("Buddy") print(dog.info()) # Inherited method: "I am Buddy" print(dog.speak()) # Overridden method: "Woof!" print(dog.fetch()) # Own method: "Buddy is fetching" cat = Cat("Whiskers") print(cat.info()) # Inherited method: "I am Whiskers" print(cat.speak()) # Overridden method: "Meow!"

Polymorphism Example:

# Polymorphism - same method, different behavior def make_animal_speak(animal): print(animal.speak()) # Works with any Animal subclass dog = Dog("Max") cat = Cat("Luna") generic_animal = Animal("Unknown") # Same function works with different object types make_animal_speak(dog) # Woof! make_animal_speak(cat) # Meow! make_animal_speak(generic_animal) # Some sound # Polymorphism with a list animals = [Dog("Rex"), Cat("Mittens"), Dog("Spot")] for animal in animals: print(f"{animal.name} says: {animal.speak()}") # Output: # Rex says: Woof! # Mittens says: Meow! # Spot says: Woof!

Multiple Inheritance:

class Flyable: def fly(self): return "Flying high" class Swimmable: def swim(self): return "Swimming" class Duck(Animal, Flyable, Swimmable): # Multiple inheritance def speak(self): return "Quack!" duck = Duck("Donald") print(duck.speak()) # Quack! print(duck.fly()) # Flying high print(duck.swim()) # Swimming print(duck.info()) # I am Donald

Key Differences:

  • Inheritance: About code reuse and creating relationships
  • Polymorphism: About interface flexibility and behavior variation
  • Inheritance is the mechanism; polymorphism is the result

19What are some common uses of the re (regular expression) module in Python?

The re module provides powerful pattern matching and text manipulation capabilities.

Common Functions:

  • re.search() - Find first match anywhere in string
  • re.match() - Match at beginning of string
  • re.findall() - Find all matches as a list
  • re.sub() - Replace matches
  • re.split() - Split string by pattern
  • re.compile() - Compile pattern for reuse

Common Pattern Elements:

  • \d - Digit (0-9)
  • \w - Word character (letter, digit, underscore)
  • \s - Whitespace
  • . - Any character except newline
  • * - 0 or more occurrences
  • + - 1 or more occurrences
  • ? - 0 or 1 occurrence
  • {n} - Exactly n occurrences
  • ^ - Start of string
  • $ - End of string

Practical Examples:

import re # 1. Email validation email = "user@example.com" pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$" if re.match(pattern, email): print("Valid email") # 2. Phone number extraction text = "Call me at 123-456-7890 or 987-654-3210" phones = re.findall(r"\d{3}-\d{3}-\d{4}", text) print(phones) # ['123-456-7890', '987-654-3210'] # 3. Password validation (8+ chars, letter + number) password = "secure123" if re.match(r"^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$", password): print("Strong password") # 4. Extract URLs from text text = "Visit https://example.com or http://test.org" urls = re.findall(r"https?://[\w\.-]+\.\w+", text) print(urls) # ['https://example.com', 'http://test.org'] # 5. Replace/clean text text = "Hello World! How are you?" cleaned = re.sub(r"\s+", " ", text) # Replace multiple spaces with one print(cleaned) # "Hello World! How are you?" # 6. Remove HTML tags html = "<p>Hello <b>World</b></p>" plain_text = re.sub(r"<.*?>", "", html) print(plain_text) # "Hello World" # 7. Split by multiple delimiters text = "apple,banana;orange|grape" fruits = re.split(r"[,;|]", text) print(fruits) # ['apple', 'banana', 'orange', 'grape'] # 8. Extract hashtags tweet = "Loving #Python and #Coding today!" hashtags = re.findall(r"#\w+", tweet) print(hashtags) # ['#Python', '#Coding'] # 9. Validate date format (YYYY-MM-DD) date = "2025-11-09" if re.match(r"^\d{4}-\d{2}-\d{2}$", date): print("Valid date format") # 10. Compile pattern for reuse (more efficient) email_pattern = re.compile(r"^[\w\.-]+@[\w\.-]+\.\w+$") print(email_pattern.match("test@example.com")) # Match object print(email_pattern.match("invalid-email")) # None # 11. Groups and capturing phone = "123-456-7890" match = re.search(r"(\d{3})-(\d{3})-(\d{4})", phone) if match: area_code = match.group(1) # 123 print(f"Area code: {area_code}") print(match.groups()) # ('123', '456', '7890') # 12. Case-insensitive matching text = "Python is AWESOME" if re.search(r"python", text, re.IGNORECASE): print("Found 'python' (case-insensitive)")

Best Practices:

  • Use raw strings (r"pattern") to avoid escape issues
  • Compile patterns that are used multiple times
  • Test regex patterns using tools like regex101.com
  • Don't over-complicate - sometimes simple string methods are better

20What is the Global Interpreter Lock (GIL) in Python?

The Global Interpreter Lock (GIL) is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously.

Key Points:

  • Only one thread can execute Python code at a time
  • Exists in CPython (the standard Python implementation)
  • Simplifies memory management but limits multi-threading performance
  • Affects CPU-bound tasks more than I/O-bound tasks

Why GIL Exists:

  • Makes memory management simpler and safer
  • Prevents race conditions with reference counting
  • Allows easy integration with C libraries
  • Simplifies implementation of CPython

Impact on Performance:

import threading import time # CPU-bound task (affected by GIL) def cpu_intensive(): total = 0 for i in range(10_000_000): total += i return total # Single thread start = time.time() cpu_intensive() print(f"Single thread: {time.time() - start:.2f}s") # Multiple threads (not much faster due to GIL) start = time.time() threads = [] for _ in range(2): t = threading.Thread(target=cpu_intensive) threads.append(t) t.start() for t in threads: t.join() print(f"Two threads: {time.time() - start:.2f}s") # Often similar or slower due to GIL overhead!

Workarounds:

# 1. Use multiprocessing for CPU-bound tasks from multiprocessing import Pool def cpu_task(n): return sum(range(n)) with Pool(processes=4) as pool: results = pool.map(cpu_task, [1000000, 2000000, 3000000]) # 2. Use threading for I/O-bound tasks (GIL released during I/O) import threading import requests def fetch_url(url): response = requests.get(url) return len(response.content) urls = ['http://example.com'] * 10 threads = [] for url in urls: t = threading.Thread(target=fetch_url, args=(url,)) threads.append(t) t.start() for t in threads: t.join() # 3. Use async/await for I/O operations import asyncio async def async_task(): await asyncio.sleep(1) return "Done" async def main(): tasks = [async_task() for _ in range(10)] results = await asyncio.gather(*tasks) asyncio.run(main()) # 4. Use alternative Python implementations # - Jython (Java-based, no GIL) # - IronPython (.NET-based, no GIL) # - PyPy (JIT compiler, still has GIL but faster)

When GIL Matters:

  • CPU-bound tasks: Use multiprocessing
  • I/O-bound tasks: Threading works fine (GIL released during I/O)
  • Network requests: async/await or threading
  • Data processing: NumPy/Pandas (release GIL internally)

The GIL is not a limitation for most applications, especially I/O-bound ones like web servers.

21How does file handling work in Python? Why is with open(...) as ...: preferred?

Python provides several ways to work with files, but the with statement is the recommended approach.

Basic File Operations:

  • 'r' - Read mode (default)
  • 'w' - Write mode (overwrites existing content)
  • 'a' - Append mode
  • 'r+' - Read and write mode

Why use with statement?

  • Automatically closes the file (even if exceptions occur)
  • Cleaner and more readable code
  • Prevents resource leaks
  • No need to explicitly call close()

Examples:

# OLD WAY - Not recommended file = open('data.txt', 'r') content = file.read() file.close() # Must remember to close! # RECOMMENDED WAY - Using with statement with open('data.txt', 'r') as file: content = file.read() # File automatically closes after this block # Reading files - different methods with open('data.txt', 'r') as file: # Read entire file content = file.read() # Read line by line for line in file: print(line.strip()) # Read all lines into a list lines = file.readlines() # Writing to files with open('output.txt', 'w') as file: file.write("Hello, World!\n") file.write("Second line\n") # Appending to files with open('log.txt', 'a') as file: file.write("New log entry\n") # Reading and writing with open('data.txt', 'r+') as file: content = file.read() file.write("\nNew content") # Working with multiple files with open('input.txt', 'r') as infile, open('output.txt', 'w') as outfile: for line in infile: outfile.write(line.upper()) # Exception handling with files try: with open('data.txt', 'r') as file: content = file.read() except FileNotFoundError: print("File not found") except PermissionError: print("Permission denied")

The with statement uses context managers (__enter__ and __exit__ methods) to ensure proper resource management.