These Python interview questions cover the language rules behind everyday code: mutability, equality, argument passing, scope, classes, exceptions and iteration. Give the short answer first, then use the example to explain what changes and what stays shared.
The fundamentals suit junior interviews; the later sections add memory, concurrency, decorators and data-library questions. Examples use the stated Python versions. Match preparation to the role rather than assuming every employer asks every topic.
The following ten questions provide a quick revision checklist and link to their full explanations. They are a useful starting point, not a measured prediction of what every interviewer will ask.
- What is the difference between a list and a tuple? A list can change after we create it, whereas a tuple cannot; a tuple can be a dictionary key when all its elements are hashable (question 2.3).
- What is the difference between == and is? The == operator checks whether two values are equal, whereas the is operator checks whether two names point to the same object (question 1.7).
- *What are *args and *kwargs? The *args parameter collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict (question 4.2).
- Does Python pass arguments by value or by reference? Neither. Python passes a reference to the object, so a function can change a mutable argument, such as a list, in place, but it cannot make the caller’s variable point to a new object (question 4.3).
- Why is a mutable default argument a bug? Python creates the default list only once, when it defines the function, so every call that uses the default shares the same list (question 4.4).
- What is a decorator? A decorator is a function that takes a function and returns a wrapped version of it, and we apply it with @name above the function (question 8.6).
- What is a generator? A generator is a function that uses yield to produce values one at a time, on demand (question 8.7).
- What is the difference between a shallow copy and a deep copy? A shallow copy copies only the outer object, so the copy shares the nested objects with the original. A deep copy copies the nested objects too (question 8.9).
- What is the GIL? The Global Interpreter Lock lets only one thread at a time run Python bytecode in CPython, although CPython also offers an optional free-threaded build without that lock (question 8.3).
- What does if name == “main” do? The code under the name check runs only when we run the file as a script, not when another module imports the file (question 6.2).
The following example shows five of these answers in code, with the result of each line as a comment.
# 1. A tuple cannot change
fruits = ["apple", "banana"]
fruits.append("cherry")
point = (1, 2)
try:
point[0] = 5
except TypeError:
print("a tuple cannot be changed")
# 2. == compares values, is compares identity
a = [1, 2]
b = [1, 2]
same = a == b # True
identical = a is b # False
# 3. *args and **kwargs
def show(*args, **kwargs):
return args, kwargs
result = show(1, 2, color="red") # ((1, 2), {'color': 'red'})
# 5. The default list is shared between calls
def add_item(item, items=[]):
items.append(item)
return items
first = add_item("a") # ['a']
second = add_item("b") # ['a', 'b']
# 7. A generator produces values on demand
squares = (n * n for n in range(4))
values = list(squares) # [0, 1, 4, 9]
Notice that the second add_item() call returns [‘a’, ‘b’], because both calls share one default list.
The remaining questions follow the topics of a typical interview, starting with basics, data types, strings, functions and scope, and moving on to object-oriented programming (OOP), modules and files, and exceptions. After that come the advanced topics (memory, the GIL, concurrency, decorators, generators and copying), followed by NumPy, pandas and coding problems.
1. Python Basics Interview Questions
Basic questions check how Python runs our code and how its syntax differs from Java or C++, so interviewers often start here with freshers.
1.1. Is Python an Interpreted or a Compiled Language?
For CPython, source is compiled to bytecode and executed by the interpreter. That is an implementation model, not a requirement that every Python implementation executes code identically. Distinguish the Python language from a particular runtime.
Bytecode is a list of simple instructions for the virtual machine, not machine code. The compile step happens when we run or import a file, so there is no separate build step as in Java or C++.
Python compiles the whole file before running it, so a syntax error stops the program before any line runs. In the next snippet, the first print() never prints, because the missing parenthesis on the second line stops the compile step.
print("start")
print("end"
SyntaxError: '(' was never closed
Other errors, such as TypeError or NameError, appear only at run time, when Python reaches the line. To catch them earlier, we use linters and type checkers such as ruff and mypy, which check the code before it runs.
CPython also has an experimental JIT (just-in-time) compiler, which turns often-used code into machine code while the program runs. The JIT arrived in 3.13, and the official Windows and macOS binaries of 3.14 include it, but it is off by default.
1.2. What Is a Dynamically Typed Language?
In a dynamically typed language, types are checked while the program runs, not before. The type belongs to the object, not to the variable, so the same variable can refer to an int and later to a str. Java and C++ are statically typed, which means we declare the type and the compiler checks it. Ruby and JavaScript are dynamically typed, like Python.
Python is also strongly typed, so it does not convert a str to an int for us, and adding the two raises TypeError.
x = 5
kind = type(x) # <class 'int'>
x = "five"
kind = type(x) # <class 'str'>
bad = "5" + 5 # TypeError: can only concatenate str (not "int") to str
We do not declare variable types in Python. Type hints such as age: int are optional notes about the expected type, and Python ignores them at run time. Tools such as mypy read the hints and find mistakes before the code runs (question 4.9). Python itself accepts a str for an int hint.
age: int = "ten" # age = 'ten'
Dynamic typing costs some safety, because a wrong type shows up only when the line runs. For example, a form handler that gets “ten” as an age fails only when the code does math with it, which may happen in production. Type hints and tests find most type errors early.
1.3. What Is PEP 8?
PEP 8 is the official style guide for Python code, written by Guido van Rossum, Barry Warsaw and Alyssa Coghlan. When every developer follows PEP 8, all code looks the same and reviews go faster. Interviewers ask most often about indentation, line length, naming, blank lines and imports.
- Indent with 4 spaces, never tabs.
- Keep lines at most 79 characters long.
- Use snake_case for functions and variables, CapWords for classes and UPPER_CASE for constants.
- Put two blank lines around top-level functions and classes.
- Put imports at the top of the file, one module per line.
MAX_RETRIES = 3
class OrderService:
def total_price(self, prices):
return sum(prices)
PEP 8 is a convention, not a language rule, so Python runs code that breaks it. Formatters and linters such as ruff and black apply the rules for us. A formatter rewrites the layout of our code, and a linter reports the lines that break the rules.
1.4. Is Indentation Required in Python?
Yes, Python uses indentation, not curly braces, to mark a block of code. A block follows if, for, while, def, class and similar statements, and a missing indent raises IndentationError.
age = 20
if age > 18:
print("adult")
IndentationError: expected an indented block after 'if' statement on line 2
Indenting the print() line by 4 spaces fixes the error. Python raises TabError when a block mixes tabs and spaces inconsistently. Every if-else statement and every for loop body follows the same indentation rule.
1.5. What Are Python Literals?
A literal is a fixed value that we write as is in the code, such as 5 in x = 5. Python has literals for numbers, text, booleans and None.
| Kind | Examples |
|---|---|
| Integer | 10, 0x1F (hex), 0b101 (binary), 1_000_000 |
| Float and complex | 3.14, 1e-3, 2j |
| String and bytes | ‘a’, “b”, “””c”””, f”{x}”, r”\n”, b”data” |
| Boolean | True, False |
| None | None |
hex_value = 0x1F # 31
binary = 0b101 # 5
million = 1_000_000 # 1000000
z = 2 + 3j # (2+3j)
Number and string objects are immutable, which means they cannot change, but a variable is only a name for an object. So after x = 5, the statement x = 10 is valid, because the assignment makes the name x refer to a different object, and the object 5 stays unchanged.
1.6. What Are break, continue and pass in Python?
The break and continue statements change the normal flow of a loop, whereas pass does nothing and only fills a place where Python needs a statement.
- The break statement ends the loop at once.
- The continue statement skips the rest of the current iteration and starts the next one.
- The pass statement does nothing. Python needs a statement in some places, for example in an empty function body, and pass fills that place until we write the real code.
For example, a loop that searches a list of orders for the first unpaid one uses break to stop at the match, and continue to skip the cancelled orders.
for i in range(10):
if i == 5:
continue
print(i, end=" ")
0 1 2 3 4 6 7 8 9
for i in range(10):
if i == 5:
break
print(i, end=" ")
0 1 2 3 4
def send_email(address):
pass
result = send_email("a@example.com") # None
1.7. What Is the Difference Between == and is?
The == operator checks whether two values are equal, whereas the is operator checks whether two names point to the same object in memory. So two separate lists with the same items are equal but not identical. Interviewers often ask about in and not in the same question. The in operator checks whether a collection, such as a list or a dict, contains an item, and not turns True into False and False into True.
a = [1, 2, 3]
b = [1, 2, 3]
same = a == b # True
identical = a is b # False
c = a
same_object = c is a # True
found = 3 in a # True
missing = 10 not in a # True
empty = not a # False
result = None
no_result = result is None # True
We use is only for singletons such as None, and == for everything else. CPython caches small integers and some strings (it reuses the same object for them), so is on numbers or strings may give different results in different places.
1.8. How Do You Write Multi-Line Comments in Python?
Python has only single-line comments, which start with #. For several lines, we put # at the start of each line, and most editors do this with one shortcut.
# Calculate the total price
# including a 10% discount
total = 100 * 0.9 # total = 90.0
Developers often use a triple-quoted string on its own line as a block comment. Python evaluates the string and throws the result away, so it acts like a comment, but it is not a real comment. As the first statement of a function or a class body, the string becomes the docstring (question 4.8), and the same applies at the top of a module.
1.9. What Do help() and dir() Do?
The help() function prints the documentation of an object, and the dir() function returns the names (attributes and methods) defined on it. Both functions help in the interactive shell when we explore a module we do not know.
import math
has_sqrt = "sqrt" in dir(math) # True
last_three = dir("text")[-3:] # ['translate', 'upper', 'zfill']
help(math.sqrt)
Help on built-in function sqrt in module math:
sqrt(x, /)
Return the square root of x.
1.10. How does modern Python differ from legacy Python?
Modern Python uses print() as a function, separates Unicode text from bytes, and uses true division for /. Old tutorials may use syntax such as print “hi”, xrange() or dict.has_key(). Translate those forms before using the example in a current project.
| Behavior | Legacy form | Modern form |
|---|---|---|
| print “hi” | print(“hi”) | |
| Division | 7 / 2 truncated for integers | 7 / 2 gives 3.5; 7 // 2 gives 3 |
| Text | str often held bytes | str holds Unicode text; bytes holds bytes |
| Ranges | xrange() iterated lazily | range() iterates lazily |
| Key test | d.has_key(k) | k in d |
half = 7 / 2 # 3.5
floor_half = 7 // 2 # 3
stock = {"apple": 5}
found = "apple" in stock # True
The code uses current syntax. It demonstrates behavior rather than asking readers to run incompatible legacy syntax.
1.11. What Is the Difference Between range and xrange?
The legacy xrange() function is absent from current Python; range() provides lazy iteration. Lazy means that a range object does not build a list of numbers; it stores only start, stop and step and computes each number when we ask for it. So even a range of a trillion numbers is tiny, and it still supports len(), indexing, slicing and fast in checks.
import sys
r = range(0, 10, 2)
numbers = list(r) # [0, 2, 4, 6, 8]
size = len(r) # 5
part = r[1:3] # range(2, 6, 2)
big = range(10**12)
size_in_bytes = sys.getsizeof(big) # 48
found = 999_999 in big # True
old = xrange(5) # NameError: name 'xrange' is not defined
2. Python Data Types and Collections Questions
Questions about data types check whether we can pick the right collection for a job and whether we know which types can change.
2.1. What Are the Built-in Data Types in Python?
Python has built-in types for numbers, text, sequences, sets and mappings (types that store key-value pairs). In the table, each row is one category with its types and a literal example.
| Category | Types | Example |
|---|---|---|
| Numeric | int, float, complex | 42, 3.14, 2j |
| Boolean | bool | True |
| Text | str | “hello” |
| Sequence | list, tuple, range | [1, 2], (1, 2), range(3) |
| Binary | bytes, bytearray, memoryview | b”abc” |
| Set | set, frozenset | {1, 2} |
| Mapping | dict | {“apple”: 5} |
| None | NoneType | None |
int_type = type(42) # <class 'int'>
float_type = type(3.14) # <class 'float'>
bool_type = type(True) # <class 'bool'>
dict_type = type({"apple": 5}) # <class 'dict'>
none_type = type(None) # <class 'NoneType'>
is_int = isinstance(True, int) # True
The last line surprises many candidates, because bool is a subclass of int, so True == 1.
2.2. What Is the Difference Between Mutable and Immutable Types?
A mutable object can change after we create it, whereas an immutable object cannot. For an immutable object, every “change” creates a new object, so name.upper() returns a new string and leaves name unchanged.
| Mutable | Immutable |
|---|---|
| list, dict, set, bytearray, most user classes | int, float, bool, str, tuple, frozenset, bytes |
name = "lokesh"
upper = name.upper() # upper = 'LOKESH', name = 'lokesh'
scores = [90, 80]
scores.append(70) # scores = [90, 80, 70]
places = {(1, 2): "home"}
place = places[(1, 2)] # 'home'
bad = {[1, 2]: "home"} # TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
Dictionary keys and set members must be hashable, with consistent equality and a stable hash. Immutability alone is not sufficient: a tuple containing a list is unhashable. User-defined mutable instances can also be hashable, though changing equality-relevant state is unsafe.
2.3. What Is the Difference Between a List and a Tuple?
A list can change, and a tuple cannot. We use a list for a collection that grows or changes, and a tuple for a fixed group of values, such as a point (x, y) or a database row.
my_list = [1, 2, 3, "hello", True]
my_tuple = (1, 2, 3, "hello", True)
my_list[0] = 10 # my_list = [10, 2, 3, 'hello', True]
my_tuple[0] = 10 # TypeError: 'tuple' object does not support item assignment
| List | Tuple | |
|---|---|---|
| Syntax | [1, 2, 3] | (1, 2, 3), and (1,) for one item |
| Mutable | Yes. Has append(), insert(), remove(), sort() | No. Has only count() and index() |
| Hashable (dict key, set item) | No | Yes, when all items are hashable |
| Memory | Larger (spare room for growth) | Smaller |
| Typical use | Items of the same kind that change | A fixed record of different fields |
import sys
list_size = sys.getsizeof([1, 2, 3]) # 88
tuple_size = sys.getsizeof((1, 2, 3)) # 72
A list keeps spare room so that append() stays fast, so the same three items need more memory in a list than in a tuple.
2.4. What Is the Difference Between an Array and a List?
A list stores references to arbitrary objects. array.array stores values of a supported type code compactly, including numeric types and supported character representations. NumPy supplies a separate array model for vectorized numeric work.
| array.array | list | |
|---|---|---|
| Item types | One numeric type, set by a type code such as “i” | Any mix of types |
| Mutable and growable | Yes | Yes |
| Memory | Compact, raw numbers | One pointer per item plus the objects |
| Import | from array import array | Built in |
| Typical use | Large lists of numbers, binary I/O | Everything else |
from array import array
nums = array("i", [1, 2, 3])
nums.append(4) # nums = array('i', [1, 2, 3, 4])
nums.append("x") # TypeError: 'str' object cannot be interpreted as an integer
2.5. What Is Slicing in Python?
Slicing selects items using start, stop and step, with stop excluded. Its result and copying behavior depend on the type: a list slice is a new list sharing element references, while some immutable types may reuse objects or return another range description.
nums = [1, 2, 3, 4, 5]
middle = nums[1:4] # [2, 3, 4]
first_two = nums[:2] # [1, 2]
tail = nums[3:] # [4, 5]
every_other = nums[::2] # [1, 3, 5]
letters = "Hello, world!"[::2] # 'Hlo ol!'
past_end = nums[1:100] # [2, 3, 4, 5]
Built-in sequence slicing generally clips out-of-range boundaries instead of raising IndexError. A zero step is invalid, and user-defined types can implement different behavior. Do not generalize a list example to every object with slicing syntax.
2.6. What Are Negative Indexes and What Does [::-1] Do?
A negative index counts from the end: -1 is the last item, and -2 is the one before it. So we get the last items without computing len(seq) – 1. A slice with step -1 walks backward, so [::-1] returns a reversed copy.
nums = [1, 2, 3, 4, 5]
last = nums[-1] # 5
second_last = nums[-2] # 4
last_three = nums[-3:] # [3, 4, 5]
reversed_text = "hello"[::-1] # 'olleh'
reversed_nums = nums[::-1] # [5, 4, 3, 2, 1]
word = "Hello, World!"[-6:-1] # 'World'
Sets and dicts do not support indexes at all. A dict keeps insertion order, but we read its values by key, not by position.
2.7. What Are List, Dict and Set Comprehensions?
A comprehension builds a new list, dict or set in one line. The list comprehension pattern is [expression for item in iterable if condition], where an iterable is anything we can loop over, such as a list or a string. A comprehension is shorter than a for loop with append(), and often a little faster.
import math
squares = [x**2 for x in range(1, 6)] # squares = [1, 4, 9, 16, 25]
evens = [x for x in range(10) if x % 2 == 0] # evens = [0, 2, 4, 6, 8]
facts = {x**2: math.factorial(x) for x in range(1, 6)} # facts = {1: 1, 4: 2, 9: 6, 16: 24, 25: 120}
letters = {c for c in "banana"}
unique = sorted(letters) # ['a', 'b', 'n']
gen = (x**2 for x in range(3))
gen_type = type(gen) # <class 'generator'>
values = tuple(gen) # (0, 1, 4)
Python has no tuple comprehension, because parentheses create a generator expression instead. A generator expression produces values lazily, one at a time, and tuple() turns it into a tuple.
2.8. What Is the Difference Between remove(), pop() and del?
The remove() and pop() methods and the del statement all delete items from a list. The remove() method deletes by value, whereas pop() and del delete by position.
| Operation | Removes by | Returns | Error when missing |
|---|---|---|---|
| lst.remove(x) | Value (first match) | None | ValueError |
| lst.pop(i) | Index (the last item when we pass no index) | The removed item | IndexError |
| del lst[i] | Index or slice | Nothing (a statement) | IndexError |
nums = [1, 2, 3, 2, 5]
nums.remove(2) # nums = [1, 3, 2, 5]
last = nums.pop() # last = 5, nums = [1, 3, 2]
del nums[0] # nums = [3, 2]
nums.remove(9) # ValueError: list.remove(x): x not in list
The del statement also deletes dict entries (del d[“key”]) and names (del x), and using a deleted name raises NameError. Since remove() deletes only the first match, we build a new list to remove every copy of a value, such as [n for n in nums if n != 2].
2.9. What Is the Difference Between append() and extend()?
The append() method adds its argument as one item, whereas extend() adds every item of an iterable. So append([3, 4]) adds one list inside the list, and extend([3, 4]) adds two numbers.
a = [1, 2]
a.append([3, 4]) # a = [1, 2, [3, 4]]
b = [1, 2]
b.extend([3, 4]) # b = [1, 2, 3, 4]
c = b + [5] # [1, 2, 3, 4, 5]
2.10. Are Python Dictionaries Ordered?
Python dictionaries preserve insertion order. Updating an existing key does not move it to the end, while deleting and reinserting does.
Today, OrderedDict is useful mainly for two features. Its move_to_end() method moves a key to either end, and two OrderedDict objects are equal only when their keys are in the same order.
ages = {}
ages["Lokesh"] = 37
ages["Alex"] = 25
ages["Brian"] = 30
names = list(ages) # ['Lokesh', 'Alex', 'Brian']
john = ages.get("John") # None
john_or_zero = ages.get("John", 0) # 0
john_age = ages["John"] # KeyError: 'John'
A set does not keep insertion order.
2.11. What Is the Difference Between sort() and sorted()?
The list.sort() method changes the list itself and returns None, whereas the sorted() function returns a new sorted list. The sorted() function also accepts any iterable, such as a string or a tuple. Both use Timsort, a mix of merge sort and insertion sort, which is stable, so items with equal keys keep their original order.
nums = [3, 1, 2]
result = nums.sort() # result = None, nums = [1, 2, 3]
letters = sorted("cba") # ['a', 'b', 'c']
words = ["banana", "kiwi", "apple"]
by_length = sorted(words, key=len) # ['kiwi', 'apple', 'banana']
descending = sorted(words, reverse=True) # ['kiwi', 'banana', 'apple']
2.12. What Is Type Conversion in Python?
Type conversion changes a value from one type to another. Python converts types on its own only between number types, so 1 + 2.5 turns 1 into a float. For every other conversion, we call a constructor such as int(), float(), str(), list(), tuple(), set() or dict().
number = int("10") # 10
text = str(10) # '10'
price = float("3.5") # 3.5
whole = int(3.9) # 3
mixed = 1 + 2.5 # 3.5
chars = list("abc") # ['a', 'b', 'c']
stock = dict([("apple", 5), ("banana", 3)]) # {'apple': 5, 'banana': 3}
bad = int("abc") # ValueError: invalid literal for int() with base 10: 'abc'
The call int(3.9) cuts off the decimal part and moves toward zero, so it returns 3. To round instead, we use round().
User input is the common source of bad text. For example, the quantity field of an order form can contain “abc”, so we catch ValueError and fall back to a default instead of crashing the request.
def to_int(text, default=0):
try:
return int(text)
except (TypeError, ValueError):
return default
qty = to_int(" 12 ") # 12
bad_qty = to_int("abc") # 0
no_qty = to_int(None) # 0
2.13. What Does the zip() Function Do?
The zip() function pairs items from two or more iterables by position and returns an iterator of tuples. When the inputs have different lengths, zip() stops at the shortest one. With strict=True, it raises ValueError instead.
names = ["apple", "banana", "cherry"]
prices = [5, 3, 7]
pairs = list(zip(names, prices)) # [('apple', 5), ('banana', 3), ('cherry', 7)]
price_of = dict(zip(names, prices)) # {'apple': 5, 'banana': 3, 'cherry': 7}
short = list(zip([1, 2, 3], "ab")) # [(1, 'a'), (2, 'b')]
checked = list(zip([1, 2, 3], "ab", strict=True)) # ValueError: zip() argument 2 is shorter than argument 1
2.14. What Does the enumerate() Function Do?
The enumerate() function gives us the index and the item together in a loop. Each step returns a pair (index, item), so we do not need a counter variable or range(len(seq)).
fruits = ["apple", "banana", "orange"]
for index, fruit in enumerate(fruits, start=1):
print(index, fruit)
1 apple
2 banana
3 orange
3. Python String Interview Questions
A Python string is an immutable sequence of Unicode characters, so every string method returns a new string and leaves the original unchanged.
3.1. How Do split() and join() Work?
The split() method cuts a string into a list of parts, and the join() method glues a list of strings back into one string. Both use a separator, such as a comma. Without an argument, split() splits the string at any run of whitespace.
parts = "Hello, world!".split(",") # ['Hello', ' world!']
words = "a b c".split() # ['a', 'b', 'c']
date_parts = "2026-10-04".split("-", 1) # ['2026', '10-04']
greeting = ", ".join(["Hello", "world!"]) # 'Hello, world!'
date = "-".join(["2026", "10", "04"]) # '2026-10-04'
bad = " ".join([1, 2]) # TypeError: sequence item 0: expected str instance, int found
We call join() on the separator string, not on the list. Every item must already be a string, otherwise join() raises TypeError, so for numbers we convert them first with ” “.join(map(str, nums)).
3.2. How Do You Change the Case of a String?
Python strings have one method for each kind of case change, and each method returns a new string.
| Method | Result for “hELLO wORLD” |
|---|---|
| lower() | “hello world” |
| upper() | “HELLO WORLD” |
| capitalize() | “Hello world” (first letter only, rest lowercase) |
| title() | “Hello World” |
| swapcase() | “Hello World” (each letter flipped) |
| casefold() | “hello world” (a stronger lowercase, made for comparisons) |
s = "hELLO wORLD"
lower = s.lower() # 'hello world'
upper = s.upper() # 'HELLO WORLD'
capitalized = s.capitalize() # 'Hello world'
title = s.title() # 'Hello World'
swapped = s.swapcase() # 'Hello World'
flipped = "HeLLo WoRLd".swapcase() # 'hEllO wOrlD'
folded = "stra\u00dfe".casefold() # 'strasse'
For case-insensitive comparisons, casefold() is safer than lower(), because it also converts special letters; for example, the German sharp s (code point U+00DF) becomes “ss”. For example, a search box that compares names with casefold() finds “Strasse” when the user types “STRASSE”. To change only the first letter, we call capitalize(), which also lowercases the rest.
3.3. What Are f-strings and t-strings?
An f-string (f”…”) puts the values of variables and expressions into a string. Python evaluates each expression inside {} and returns a normal str. After a colon, we can add a format specifier, such as ,.2f for two decimals, and a = after the expression prints the expression together with its value, which helps with debugging.
name = "Lokesh"
price = 1234.5
greeting = f"Hi {name}" # 'Hi Lokesh'
amount = f"{price:,.2f}" # '1,234.50'
debug = f"{price=}" # 'price=1234.5'
padded = f"{name:>10}" # ' Lokesh'
name = "<b>Lokesh</b>"
tmpl = t"Hi {name}"
kind = type(tmpl) # <class 'string.templatelib.Template'>
strings = tmpl.strings # ('Hi ', '')
values = tmpl.values # ('<b>Lokesh</b>',)
3.4. What Do split(), sub() and subn() Do in the re Module?
The re module works with regular expressions, which are patterns that describe text, such as “a comma or a space”. Its split() function cuts text at a pattern, whereas sub() and subn() replace the matches, and subn() also counts the replacements.
- The call re.split(pattern, s) splits a string at every match of the pattern.
- The call re.sub(pattern, repl, s) replaces every match and returns the new string.
- The call re.subn(pattern, repl, s) replaces every match too, but returns a tuple (new_string, count).
import re
text = "The,quick,brown fox jumps over the lazy dog"
words = re.split(r"[,\s]", text) # ['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
replaced = re.sub("fox", "cat", text) # 'The,quick,brown cat jumps over the lazy dog'
result = re.subn("o", "0", text) # ('The,quick,br0wn f0x jumps 0ver the lazy d0g', 4)
The str.split() method accepts only one fixed separator, whereas re.split() accepts a pattern, so one call splits on both commas and spaces. For example, a tag field where users type “java, spring boot;kafka” splits into three clean tags with re.split(r”[,;]\s“, text)*.
4. Functions and Scope Interview Questions
Function questions check how Python passes arguments and how default values behave. Interviewers also ask where Python looks up a name, which is the scope question.
4.1. How Do You Define and Call a Function in Python?
A function is a named block of code that we can run again and again. We define it with def, and it can take positional and keyword arguments with default values. A function without a return statement returns None. Python also has small functions without a name, written with lambda (question 4.5).
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
hello = greet("Lokesh") # 'Hello, Lokesh!'
hi = greet("Alex", greeting="Hi") # 'Hi, Alex!'
nobody = greet() # TypeError: greet() missing 1 required positional argument: 'name'
Python also comes with many built-in functions, such as print(), len(), range(), sorted() and sum().
4.2. What Are *args and **kwargs?
**The *args parameter collects any number of extra positional arguments into a tuple, and kwargs collects any number of extra keyword arguments into a dict. The names args and kwargs are only a convention, because only the single and double stars matter. When we call a function, the same stars unpack a list or a dict into arguments.
def total(*args):
return sum(args)
def describe(**kwargs):
return kwargs
summed = total(1, 2, 3) # 6
person = describe(name="John", age=30) # {'name': 'John', 'age': 30}
nums = [1, 2, 3]
unpacked = total(*nums) # 6
The order in a function signature is fixed. Regular parameters come first, then **args*, then keyword-only parameters, and **kwargs comes last. The same star also unpacks a variable-length tuple in an assignment, such as *first, rest = (1, 2, 3).
4.3. Does Python Pass Arguments by Value or by Reference?
Python binds a parameter name to the passed object, often called call by sharing or pass by assignment. Mutating a shared mutable object can affect the caller; rebinding the local parameter does not rebind the caller’s name. Explain those two operations instead of debating terminology alone.
- If the function changes a mutable object in place, for example with append() or item assignment, the caller sees the change.
- If the function assigns a new object to the parameter, only the local name changes, and the caller’s variable still points to the old object.
def add_item(items):
items.append(4)
def replace(items):
items = [0]
nums = [1, 2, 3]
add_item(nums) # nums = [1, 2, 3, 4]
replace(nums) # nums = [1, 2, 3, 4]
Numbers, strings and tuples are immutable and cannot change in place, so a function can never change the caller’s int or str. A list is different. For example, a helper that adds a discount line to an order’s item list changes the caller’s list, which surprises a caller who expected a copy.
4.4. Why Are Mutable Default Arguments a Problem?
Python creates a default value only once, when it runs the def statement, not on every call. So a mutable default, such as [] or {}, is shared by all calls that use the default. In a web app, the shared list survives between requests, so tags from one request show up in the response to the next one.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
first = add_tag("python") # ['python']
second = add_tag("java") # ['python', 'java']
The fix is to use None as the default and create a new object inside the function.
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
first = add_tag("python") # ['python']
second = add_tag("java") # ['java']
4.5. What Is a Lambda Function?
A lambda is a small function without a name, written in one line as lambda arguments: expression. We use lambdas for short callbacks (functions that we pass to another function), most often as the key of functions such as sorted() and max(), and also in map() and filter().
A lambda holds one expression only, so it cannot contain statements such as loops or return.
from functools import reduce
prices = {"apple": 5, "banana": 3, "cherry": 7}
by_price = sorted(prices, key=lambda k: prices[k]) # ['banana', 'apple', 'cherry']
doubled = list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]
bigger = list(filter(lambda x: x > 1, [1, 2, 3])) # [2, 3]
product = reduce(lambda a, b: a * b, [1, 2, 3, 4]) # 24
The map() function applies a function to every item, and filter() keeps the items for which the function returns true. The reduce() function (in functools) combines all items into one value, for example by multiplying them. A comprehension is often easier to read than map() or filter() with a lambda.
4.6. What Are Namespaces and Scope in Python?
A namespace is a table of names and the objects they point to, and a scope is the part of the code where a namespace is visible. Python looks up a name in four scopes, in a fixed order called the LEGB rule.
- Local scope holds the names assigned inside the current function.
- Enclosing scope holds the names in the outer function, for nested functions.
- Global scope holds the names assigned at the top level of the module.
- Built-in scope holds names such as len and print.
If no scope has the name, Python raises NameError. Each function call creates a new local namespace, which disappears when the call returns.
x = "global"
def outer():
x = "enclosing"
def inner():
return x
return inner()
found = outer() # 'enclosing'
size = len("abc") # 3
value = undefined_name # NameError: name 'undefined_name' is not defined
Unlike Java, Python has no block scope, so a variable assigned inside an if or for block is still visible after the block, in the same function. The globals() and locals() functions return the global and local namespaces as dicts.
4.7. What Is the Difference Between Local and Global Variables?
A local variable exists only during one function call, whereas a global variable is defined at the top level of the module and is visible in all its functions. When a function assigns to a name, the name becomes local, even if a global with the same name exists. So to assign a new value to a global, the function must declare the name global, and to assign to a variable of the enclosing function, the inner function declares it nonlocal.
count = 0
def shadow():
count = 5
return count
def increment():
global count
count += 1
def make_counter():
n = 0
def step():
nonlocal n
n += 1
return n
return step
local = shadow() # 5
before = count # 0
increment() # count = 1
counter = make_counter()
first = counter() # 1
second = counter() # 2
Without the global line, count += 1 raises UnboundLocalError, because the assignment makes count local to the function and the local count has no value yet. So we keep global variables to a minimum and pass values as arguments instead.
4.8. What Is a Docstring in Python?
A docstring is the documentation string of a module, class or function. We write it as the first statement of the body, and Python stores it in the doc attribute. The help() function shows the docstring, and tools such as Sphinx build documentation from docstrings. By convention, docstrings use triple double quotes.
def area(width, height):
"""Return the area of a rectangle."""
return width * height
doc = area.__doc__ # 'Return the area of a rectangle.'
4.9. What Are Type Hints in Python?
Type hints are optional notes that say which types a function expects and returns. Hints can also describe variables. Python stores the hints but does not check them at run time, whereas static checkers such as mypy and IDEs read them and warn us about mistakes. Annotation evaluation depends on the interpreter and syntax; type hints are still not enforced at run time (PEP 649).
def total(prices: list[float], tax: float = 0.1) -> float:
return sum(prices) * (1 + tax)
amount = round(total([10.0, 20.0]), 2) # 33.0
hints = total.__annotations__ # {'prices': list[float], 'tax': <class 'float'>, 'return': <class 'float'>}
5. Object-Oriented Programming (OOP) Questions
Python supports classes, inheritance and polymorphism, but it handles access control and overloading differently from Java, and interviewers focus on those differences.
5.1. What Is init and What Is self?
The init method sets up a new object right after Python creates it, and self is that new object. Python passes self as the first argument of every instance method, so we never pass it ourselves, and the name self is only a convention. Many people call init the constructor, but strictly, new creates the object (question 5.2).
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def describe(self):
return f"{self.year} {self.make} {self.model}"
my_car = Car("Toyota", "Corolla", 2021)
make = my_car.make # 'Toyota'
text = my_car.describe() # '2021 Toyota Corolla'
car = Car("Honda") # TypeError: Car.__init__() missing 2 required positional arguments: 'model' and 'year'
5.2. What Is the Difference Between new and init?
The new method creates the new object, and then init fills it with data. Python treats new as a static method that receives the class and returns the new instance, whereas init receives the instance and returns None. We override new only in special cases, such as singletons (classes with only one instance) and subclasses of immutable types such as int and str.
class Point:
def __new__(cls, *args):
print("1. __new__ creates the object")
return super().__new__(cls)
def __init__(self, x, y):
print("2. __init__ sets the attributes")
self.x, self.y = x, y
p = Point(1, 2)
1. __new__ creates the object
2. __init__ sets the attributes
5.3. Can We Overload Constructors or Methods in Python?
Repeated def statements with the same name do not create Java-style signature overloads: the later binding replaces the earlier one. Default arguments, argument unpacking or explicit dispatch can support different calling patterns. Operator overloading is a separate supported feature.
class Person:
def __init__(self, name, age):
self.name, self.age = name, age
def __init__(self, name):
self.name, self.age = name, 0
age = Person("Bob").age # 0
alice = Person("Alice", 25) # TypeError: Person.__init__() takes 2 positional arguments but 3 were given
We can get the same effect in three ways.
- Default arguments, such as def init(self, name, age=0).
- Class methods as named alternative constructors, such as Person.from_dict(data) (question 5.4).
- The functools.singledispatchmethod decorator, which picks an implementation by the type of the first argument.
class Person:
def __init__(self, name, age=0):
self.name, self.age = name, age
alice_age = Person("Alice", 25).age # 25
bob_age = Person("Bob").age # 0
5.4. What Is the Difference Between Instance, Class and Static Methods?
Python has three kinds of methods, and they differ in what they receive as the first argument.
| Kind | Decorator | First argument | Typical use |
|---|---|---|---|
| Instance method | none | self (the object) | Work with one object’s data |
| Class method | @classmethod | cls (the class) | Alternative constructors, class-wide data |
| Static method | @staticmethod | none | A helper that belongs to the class but needs neither the object nor the class |
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def fahrenheit(self):
return self.celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
@staticmethod
def is_freezing(celsius):
return celsius <= 0
boiling_f = Temperature(100).fahrenheit() # 212.0
boiling_c = Temperature.from_fahrenheit(212).celsius # 100.0
freezing = Temperature.is_freezing(-5) # True
5.5. Can We Call a Parent Class Method Without Creating an Instance?
Yes, in two cases. First, we call class methods and static methods on the class itself, without any object. Second, a subclass calls a parent’s method on its own object with super(). An instance method still needs some object as self, but super() means the parent part of the current object, so we never create a separate parent object.
class Animal:
count = 0
@classmethod
def describe(cls):
return f"{cls.__name__} class, {cls.count} created"
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return super().speak() + " Woof!"
info = Animal.describe() # 'Animal class, 0 created'
sound = Dog().speak() # '... Woof!'
5.6. How Do You Create an Empty Class in Python?
An empty class has pass as its body. Python needs at least one statement in a block, and pass is that statement. Empty classes are common as custom exception types and as placeholders, and we can still add attributes to an instance later.
class Order:
pass
class PaymentError(Exception):
pass
order = Order()
order.total = 99
total = order.total # 99
5.7. What Does the object() Function Do?
The object() call returns a new, empty instance of the class object, which is the base class of every Python class. Every class inherits from object even when we do not list it, so class MyClass(object): is the same as class MyClass:. The longer form is a habit from legacy code, where it distinguished new-style classes.
An object() instance has no dict, so we cannot add attributes to it. Its common use is a unique “sentinel” value, a marker value that no caller can pass by accident. For example, a cache lookup that must tell “no value stored” apart from a stored None uses a sentinel as the default.
class MyClass:
pass
mro = MyClass.__mro__ # (<class '__main__.MyClass'>, <class 'object'>)
MISSING = object()
MISSING.name = "x" # AttributeError: 'object' object has no attribute 'name' and no __dict__ for setting new attributes
def get(d, key, default=MISSING):
if key in d:
return d[key]
if default is MISSING:
raise KeyError(key)
return default
pear = get({"apple": 5}, "pear", None) # None
5.8. How Do You Check if a Class Is a Child of Another Class?
The issubclass(child, parent) function checks two classes, and the isinstance(obj, cls) function checks an object. Both return True for indirect subclasses too, such as a grandchild class, and both accept a tuple of classes.
class Animal: pass
class Dog(Animal): pass
class GoldenRetriever(Dog): pass
dog_is_animal = issubclass(Dog, Animal) # True
golden_is_animal = issubclass(GoldenRetriever, Animal) # True
animal_is_dog = issubclass(Animal, Dog) # False
golden_is_dog = isinstance(GoldenRetriever(), Dog) # True
is_number = isinstance(5, (int, float)) # True
5.9. Does Python Support Multiple Inheritance?
Yes, a class can list several base classes and inherits the methods of all of them. When two parents define the same method, Python picks one by the method resolution order (question 5.10).
The MRO also solves the diamond problem, where a class inherits the same grandparent through two parents. Still, many teams prefer small “mixin” classes or composition over deep multiple inheritance.
class Flyer:
def fly(self):
return "flying"
class Swimmer:
def swim(self):
return "swimming"
class Duck(Flyer, Swimmer):
pass
duck = Duck()
flying = duck.fly() # 'flying'
swimming = duck.swim() # 'swimming'
5.10. What Is the MRO (Method Resolution Order) in Python?
The MRO is the order in which Python searches a class and its parents for a method or attribute. Python computes it with the C3 linearization algorithm, which follows three rules.
- A child comes before its parents.
- The parents keep the order in which the class lists them.
- Every class appears once.
The mro attribute and the mro() method show the MRO of a class.
class A:
def who(self):
return "A"
class B(A):
def who(self):
return "B"
class C(A):
def who(self):
return "C"
class D(B, C):
pass
order = [cls.__name__ for cls in D.mro()] # ['D', 'B', 'C', 'A', 'object']
winner = D().who() # 'B'
D defines no who(), so Python checks B next and uses B.who(). The super() call also follows the MRO, so when the object is a D, super() in B calls C, not A.
5.11. What Is Polymorphism in Python?
Polymorphism means that the same call works on objects of different classes, and each class runs its own version of the method. In Python, polymorphism comes from method overriding and from duck typing, which means that a function accepts any object that has the needed method, whatever its class. Method overloading is not involved, because Python has no overloading (question 5.3).
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Robot:
def speak(self):
return "Beep!"
sounds = [pet.speak() for pet in (Dog(), Cat(), Robot())] # ['Woof!', 'Meow!', 'Beep!']
sizes = len("abc"), len([1, 2]) # (3, 2)
Robot shares no base class with Dog and Cat, yet the loop works, because only the speak() method matters. Built-in functions such as len() are polymorphic in the same way.
5.12. Does Python Have new and override Modifiers?
No, new and override are C# keywords, and Python has no modifiers for methods. A subclass overrides a method by defining a method with the same name, and it calls the parent version with super().
The @typing.override decorator (PEP 698) marks a method as an override, so type checkers report a typo in the method name. Python itself does not check the decorator at run time.
from typing import override
class Account:
def fee(self):
return 10
class PremiumAccount(Account):
@override
def fee(self):
return super().fee() // 2
fee = PremiumAccount().fee() # 5
5.13. What Is Encapsulation and Does Python Have Access Specifiers?
Encapsulation means that other code uses an object through its methods, not through its internal fields. Python has no public, protected or private keywords, so it uses naming conventions instead.
| Name | Meaning | Enforced? |
|---|---|---|
| name | Public | No |
| _name | Protected: internal, do not use from outside | No, only a convention |
| __name | Private. Python renames the attribute to _ClassName__name (name mangling) | Partly. The name changes |
| Module-level name | Global to the module | No |
class Account:
def __init__(self, owner, balance):
self.owner = owner
self._branch = "Delhi"
self.__balance = balance
def balance(self):
return self.__balance
acc = Account("Lokesh", 100)
owner = acc.owner # 'Lokesh'
branch = acc._branch # 'Delhi'
balance = acc.balance() # 100
hidden = acc.__balance # AttributeError: 'Account' object has no attribute '__balance'
mangled = acc._Account__balance # 100
Name mangling exists to avoid clashes with attribute names in subclasses, not as a security feature. For read-only or validated attributes, we use @property.
5.14. How Do You Do Data Abstraction in Python?
Data abstraction hides how something works and shows only what it does. In Python, we declare the interface with an abstract base class from the abc module. The class inherits from ABC and marks methods with @abstractmethod. Creating an object of an abstract class raises TypeError, so each subclass must implement the abstract methods.
from abc import ABC, abstractmethod
import math
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return round(math.pi * self.radius ** 2, 2)
rect_area = Rectangle(5, 10).area() # 50
circle_area = Circle(7).area() # 153.94
shape = Shape() # TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area'
Callers work with Shape and call area(), so they do not need to know the formula behind each shape.
5.15. What Is the Difference Between str and repr?
The str method returns a readable text for users, whereas repr returns an exact text for developers. The print() and str() functions use str, whereas the REPL, repr() and containers such as lists use repr. When a class defines only repr, Python uses it for str() too.
Both are dunder (double underscore) methods, which Python calls for us when we use built-in operations. For example, len() calls len, == calls eq and + calls add.
class Book:
def __init__(self, title, pages):
self.title, self.pages = title, pages
def __str__(self):
return self.title
def __repr__(self):
return f"Book({self.title!r}, {self.pages})"
def __len__(self):
return self.pages
book = Book("Python 101", 250)
text = str(book) # 'Python 101'
debug = repr(book) # "Book('Python 101', 250)"
books = [book] # [Book('Python 101', 250)]
pages = len(book) # 250
5.16. What Is a Data Class?
A data class is a class that holds data, and Python writes the standard methods for it. We mark the class with @dataclass and list its fields with types, and Python generates init, repr and eq from those fields, so we skip a lot of repeated code. With frozen=True, the objects cannot change and are hashable, and the dataclasses module has more options, such as order=True and slots=True.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Point:
x: int
y: int = 0
@dataclass
class Cart:
items: list = field(default_factory=list)
p = Point(1, 2) # Point(x=1, y=2)
equal = Point(1) == Point(1, 0) # True
Point(1, 2).x = 5 # FrozenInstanceError: cannot assign to field 'x'
cart = Cart() # Cart(items=[])
The call field(default_factory=list) creates a new list for every object, so the data class avoids the shared mutable default from question 4.4. For a plain [] default, @dataclass raises ValueError.
5.17. What Is del and Why Is It Rarely Used for Cleanup?
The del method runs when Python is about to destroy an object. Python calls del a finalizer, and Java’s finalize() played the same role.
Python does not promise when del runs, and at interpreter exit it may not run at all. So we do not rely on del to close files or connections, and we use a with statement (question 8.8) or try/finally for cleanup.
class Resource:
def __del__(self):
print("cleaned up")
r = Resource()
del r
cleaned up
In CPython, del r removed the last reference, so Python destroyed the object at once. With reference cycles (groups of objects that point to each other) and on other implementations such as PyPy, the call happens later.
5.18. Is Python Fully Object-Oriented?
Yes, in the sense that everything in Python is an object, including numbers, functions, classes and modules. But Python does not force us to write classes, so we can write procedural code with plain functions, or functional code with map(), filter() and lambdas. For object-oriented code, Python supports inheritance, polymorphism, encapsulation by convention and abstraction through abc.
int_is_object = isinstance(5, object) # True
bits = (5).bit_length() # 3
def f(): pass
func_is_object = isinstance(f, object) # True
meta = type(int) # <class 'type'>
6. Modules, Packages, Files and Tools
Module questions check how Python finds our code and how a project manages its libraries. Interviewers also ask how we read files, including files larger than memory.
6.1. What Are Modules and Packages in Python?
A module is one .py file, and a package is a folder of modules. In most projects, a package folder has an init.py file and can contain sub-packages, and we load both with import.
The first import runs the module once and stores the result in sys.modules, and later imports reuse that copy. A folder without init.py can also work as a namespace package.
shop/
__init__.py
cart.py
payments/
__init__.py
card.py
import math
from math import sqrt
from collections import Counter as C
import json as js
pi = math.pi # 3.141592653589793
root = sqrt(16) # 4.0
top = C("banana").most_common(1) # [('a', 3)]
text = js.dumps({"apple": 5}) # '{"apple": 5}'
With the shop layout, the statement from shop.payments.card import charge imports a function from a sub-package. We install third-party packages such as NumPy and Flask with pip (question 6.5).
6.2. What Does if name == “main” Do?
The name check lets a file run its main code only when we run the file as a script. Every module has a name variable. For the file we run, Python sets it to “main“, and for an imported file, Python sets it to the module name. So one file works both as a script and as a module that others import, and on import, the script part does not run. For example, a report.py file can define helper functions that a web app imports, and still print a report when we run it from a scheduled job.
def main():
print("Running as a script")
print("__name__ is", __name__)
if __name__ == "__main__":
main()
__name__ is __main__
Running as a script
When another file runs import greet, the output is only name is greet, and main() does not run.
6.3. What Is PYTHONPATH?
PYTHONPATH is an environment variable that lists extra folders where Python looks for modules. Python builds its search list, sys.path, in a fixed order. First comes the folder of the script, then the PYTHONPATH folders, then the standard library and site-packages (the folder where pip installs packages). We rarely need PYTHONPATH, because the usual way is to install our own project with pip install -e . inside a virtual environment.
# Linux and macOS
export PYTHONPATH=/opt/mylibs
python3 -c "import sys; print(sys.path[1])"
# /opt/mylibs
# Windows (cmd)
set PYTHONPATH=C:\mylibs
6.4. What Is the Difference Between .py and .pyc Files?
A .py file holds our source code, and a .pyc file holds the bytecode that CPython compiled from that source. CPython writes .pyc files for imported modules into a pycache folder, with the interpreter version in the file name, such as greet.cpython-314.pyc. On the next import, Python skips compiling if the source has not changed.
| .py | .pyc | |
|---|---|---|
| Content | Source code, human-readable | Bytecode, binary |
| Created by | The developer | CPython, on import |
| Location | Project folders | pycache/ next to the source |
| Portability | Any interpreter that supports the syntax | Compatible interpreter build; works on any OS |
| Speed | Same run speed | Faster start-up only, because the compile step is skipped. The code does not run faster |
python3 -m py_compile greet.py
ls __pycache__
# greet.cpython-314.pyc
6.5. What Are pip and Virtual Environments?
The pip tool installs packages, and a virtual environment keeps each project’s packages separate. The pip tool downloads the packages from PyPI, the Python Package Index. A virtual environment is a folder with its own link to the interpreter and its own packages, so when each project gets its own environment, two projects can use different versions of the same library.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install requests
pip show requests | grep Requires
# Requires: certifi, charset_normalizer, idna, urllib3
pip freeze > requirements.txt
The Requires line shows the transitive dependencies, the packages that requests itself needs, which pip installed along with it.
To get the same versions on every machine, we pin all versions with pip freeze into requirements.txt. A lock file from a tool such as uv or Poetry does the same job. Without a virtual environment, upgrading a library for one project can break another project on the same laptop.
6.6. What Is the Shortest Way to Read and Print a Text File?
The safe short way is with open(…), because the with block always closes the file, even when an error happens. For a one-liner, we use pathlib.
from pathlib import Path
with open("notes.txt", encoding="utf-8") as f:
content = f.read() # content = 'Buy milk\nCall Alex\n'
lines = Path("notes.txt").read_text(encoding="utf-8").splitlines() # ['Buy milk', 'Call Alex']
We pass encoding=”utf-8″ because the default encoding depends on the operating system. For large files, we read the file line by line instead (question 6.7).
When the file may not exist, such as a config file that the user has not created yet, we catch FileNotFoundError and use a default.
try:
settings = Path("settings.txt").read_text(encoding="utf-8")
except FileNotFoundError:
settings = "" # settings = ''
6.7. How Do You Read an 8 GB File in Python?
We read a big file in small pieces, never all at once with read(), so memory use stays small whatever the file size. For a text file, a for loop reads one line at a time through an internal buffer, and for a binary file, we read chunks of a fixed size.
line_count = 0
with open("big.txt", encoding="utf-8") as f:
for line in f:
line_count += 1
total_bytes = 0
with open("big.txt", "rb") as f:
while chunk := f.read(1024 * 1024):
total_bytes += len(chunk)
print(line_count) # 6
print(total_bytes) # 78
The := (walrus) operator assigns the chunk and tests it in one expression. Another option is the mmap module, which maps the file into virtual memory, so the operating system loads only the pages that our code accesses. That way, a 64-bit process can work with a file larger than RAM. For example, a nightly job that counts the lines of an 8 GB access log needs only the memory of one buffer.
6.8. What Are Unit Tests in Python?
A unit test is a small automatic test that runs one piece of code, such as a function, in isolation. Python comes with unittest, a JUnit-style framework with test classes, but most projects use pytest, which runs plain functions with assert.
Good unit tests are fast and independent of each other. They avoid real databases and network calls, and unittest.mock replaces those calls with fake objects.
import pytest
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be 0-100")
return round(price * (1 - percent / 100), 2)
def test_discount():
assert apply_discount(200, 10) == 180.0
def test_invalid_percent():
with pytest.raises(ValueError):
apply_discount(200, 150)
pytest -q test_prices.py
# .. [100%]
# 2 passed in 0.00s
6.9. What Is Tkinter?
Tkinter is the GUI library that comes with Python. A GUI is a graphical user interface with windows and buttons. Tkinter is a wrapper around the Tcl/Tk toolkit and runs on Windows, macOS and Linux.
Tkinter provides widgets such as windows, labels, buttons and text fields. A Tkinter program is event-driven, so mainloop() waits for clicks and key presses and calls our handler functions.
import tkinter as tk
root = tk.Tk()
root.title("Hello")
tk.Label(root, text="Hello, Tkinter!").pack(padx=20, pady=10)
tk.Button(root, text="Close", command=root.destroy).pack(pady=10)
root.after(500, root.destroy)
root.mainloop()
The root.after(500, root.destroy) line closes the window after half a second, so the example also runs without anyone clicking.
7. Exception Handling Interview Questions
An exception is an object that Python raises when an error happens. If no code catches the exception, the program stops and prints a traceback, the list of calls that led to the error.
7.1. How Do try, except, else and finally Work?
try contains the operation, except handles a matching exception and else runs after the try suite completes without an exception or early control transfer. finally normally runs as control leaves the statement. Abrupt process termination is outside that cleanup guarantee.
def divide(a, b):
try:
result = a / b
except ZeroDivisionError as e:
print("error:", e)
return None
else:
print("no error")
return result
finally:
print("finally runs")
divide(10, 2)
divide(1, 0)
no error
finally runs
error: division by zero
finally runs
For example, a payment call that times out still releases its database connection in the finally block. Interviewers also ask about four more rules.
- One except can catch several types with a tuple, such as except (ValueError, TypeError) as e:. Some interpreters accept an unparenthesized form, but the tuple form is clearer and more portable.
- Order matters. Python runs the first matching except block, so specific exceptions go before general ones.
- A bare except: also catches KeyboardInterrupt and SystemExit, so we catch Exception instead.
- A return, break or continue inside finally hides exceptions. Avoid control flow that suppresses an exception in finally (PEP 765).
7.2. How Do You Raise and Create a Custom Exception?
We raise an exception with raise, and we create a custom exception by subclassing Exception. A custom type lets callers catch our specific error, so a checkout page can show a “not enough balance” message for InsufficientFundsError and a generic message for everything else. The form raise … from e links the new exception to the original cause, so the traceback shows both.
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
super().__init__(f"balance {balance} is less than {amount}")
self.balance = balance
self.amount = amount
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
left = withdraw(100, 30) # 70
failed = withdraw(100, 500) # InsufficientFundsError: balance 100 is less than 500
def parse_age(text):
try:
return int(text)
except ValueError as e:
raise RuntimeError("bad age") from e
try:
parse_age("ten")
except RuntimeError as e:
print(e, "| caused by:", repr(e.__cause__))
bad age | caused by: ValueError("invalid literal for int() with base 10: 'ten'")
7.3. What Are Exception Groups and *except**?
An ExceptionGroup carries several exceptions at once, and except handles each type inside the group separately.* Exception groups come from code that runs several tasks together, such as asyncio.TaskGroup, where more than one task can fail.
try:
raise ExceptionGroup("batch failed", [ValueError("bad price"), TypeError("bad id")])
except* ValueError as eg:
print("values:", eg.exceptions)
except* TypeError as eg:
print("types:", eg.exceptions)
values: (ValueError('bad price'),)
types: (TypeError('bad id'),)
With plain except, only one block runs, whereas with *except**, more than one block can run for one group.
8. Advanced Python Interview Questions
Experienced candidates get questions about memory and concurrency (doing several tasks at once). Interviewers also ask about the features that make Python code short, such as decorators and generators.
8.1. How Is Memory Managed in Python?
CPython mainly combines reference counting with a collector for unreachable cycles. Do not rely on one universal moment of destruction across Python implementations or builds. Use context managers for resource cleanup rather than waiting for garbage collection.
Python stores objects in a private heap, a memory area managed by the Python memory manager, which uses its own allocator for small objects.
import sys, gc
data = [1, 2, 3]
alias = data
refs = sys.getrefcount(data) # 3
class Node:
pass
a, b = Node(), Node()
a.other, b.other = b, a
del a, b
collected = gc.collect() # 2
The getrefcount() function reports one extra reference, because the function’s own argument also points to the list. The call gc.collect() returned 2, because the collector found the two Node objects of the cycle.
8.2. Why isn’t all memory freed when Python exits?
Do not rely on Python object finalizers to clean up files and connections at shutdown. CPython may retain allocator blocks for reuse while it runs, and the operating system reclaims the process address space after it exits.
So we do not rely on del for cleanup at exit. Instead, we close files and connections with with blocks or register a cleanup function with the atexit module.
application objects --> interpreter allocator --> process memory
| |
reusable blocks returned to OS
An object can become unreachable while the process still holds allocator arenas for reuse. When the process exits, the operating system reclaims its address space; an external profiler may report memory differently depending on what it measures.
8.3. What Is the GIL and What Is Free-Threaded Python?
The Global Interpreter Lock (GIL) is a lock in CPython that lets only one thread run Python bytecode at a time. It keeps reference counts correct when several threads run, without a lock on every object. The cost is that threads do not run CPU-bound Python code (code that spends its time computing, not waiting) in parallel on several cores.
For example, a script that parses millions of log lines in pure Python gets no speedup from threads on the standard build. Threads still help with I/O, because a thread releases the GIL while it waits for the network or the disk. NumPy also releases the GIL in many operations.
CPython also offers an optional free-threaded build without the GIL (PEP 703). A free-threaded build is a separate interpreter build. Check the build and runtime before claiming that threads can execute Python code in parallel.
Performance and extension compatibility depend on the build and workload. An extension that does not support free threading can cause the GIL to be enabled again; see the free-threading guide.
import sys
import sysconfig
free_threaded_build = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
gil_check = getattr(sys, "_is_gil_enabled", None)
print("free-threaded build:", free_threaded_build)
print("GIL enabled:", gil_check() if gil_check else "check unavailable")
The first line reports build support; the second reports runtime state when the interpreter exposes that check. They can differ if an extension enables the GIL.
The difference shows when we run the same CPU-bound job in 2 threads on a 2-core machine.
import time
from concurrent.futures import ThreadPoolExecutor
def count(n):
total = 0
for i in range(n):
total += i
return total
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(count, [10_000_000] * 2))
print(f"{time.perf_counter() - start:.2f}s")
python3.14 cpu_threads.py # 0.54s (GIL: the threads take turns)
python3.14t cpu_threads.py # 0.25s (no GIL: the threads run in parallel)
The shown timings belong to the recorded workload. Free-threaded builds can run Python code on multiple cores, but actual speedup depends on contention, extensions and overhead. Benchmark the task and make shared-state access safe.
8.4. When Should We Use Threads, Processes or asyncio?
We choose by the kind of work. For work that mostly waits on I/O, such as network calls, we use threads or asyncio, and for CPU-heavy work on the standard build, we use processes.
| Tool | Module | Runs in parallel on several cores? | Good for |
|---|---|---|---|
| Threads | threading, ThreadPoolExecutor | No with the GIL. Yes on the free-threaded build | Network calls, file I/O, a few hundred tasks |
| Processes | multiprocessing, ProcessPoolExecutor | Yes. Each process has its own interpreter and GIL | CPU-heavy work. Data passes between processes by pickling |
| asyncio | asyncio | No. One thread switches between tasks at each await | Thousands of network connections open at the same time |
| Sub-interpreters | concurrent.interpreters (3.14) | Yes. Each interpreter has its own GIL | CPU work in one process, with separate state for each interpreter |
import time
from concurrent.futures import ThreadPoolExecutor
def download(n):
time.sleep (0.2)
return n * 10
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(download, [1, 2, 3, 4])) # results = [10, 20, 30, 40]
elapsed = round(time.perf_counter() - start, 1) # 0.2
The example overlaps four simulated waits. Real API requests have connection, scheduling and processing overhead, so total time is not guaranteed to equal only the slowest call. A concurrency limit and timeouts are still needed for a production client.
8.5. What Are async and await in Python?
async def defines a coroutine function. Calling it creates a coroutine object; await runs or suspends it according to the awaited operation. Awaiting does not always suspend, and blocking work in the event-loop thread can prevent other tasks from progressing.
In asyncio, the run() function starts the event loop, and gather() runs several coroutines at the same time.
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep (delay)
return f"{name} done"
async def main():
return await asyncio.gather(fetch("a", 0.2), fetch("b", 0.1))
start = time.perf_counter()
results = asyncio.run(main()) # ['a done', 'b done']
elapsed = round(time.perf_counter() - start, 1) # 0.2
A blocking call such as time.sleep inside a coroutine stops the whole event loop. So inside async code, we use the asyncio versions or a library such as httpx.
8.6. What Are Decorators in Python?
A decorator adds behavior to a function without changing the function’s code. The decorator is itself a function that takes our function and returns a new function that wraps it, and the wrapper runs extra code before or after the call.
The @decorator line above a def is short for func = decorator(func). We use decorators for logging, timing, caching (functools.cache) and access checks. Frameworks such as Flask use them too (@app.route). For example, a team that wants to log how long each of its 30 service methods takes writes one timing decorator instead of 30 copies of the timing code.
import time
from functools import wraps
def time_it(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.1f}s")
return result
return wrapper
@time_it
def slow_add(a, b):
time.sleep (0.1)
return a + b
total = slow_add(2, 3) # 5
name = slow_add.__name__ # 'slow_add'
slow_add took 0.1s
The @wraps(func) line copies the name and docstring of the original function to the wrapper. Without @wraps, slow_add.name would be ‘wrapper’.
8.7. What Is the Difference Between an Iterator and a Generator?
A generator is a function with yield, and it is the short way to write an iterator. An iterator is any object with iter() and next() methods, where each next() call returns the following value and, at the end, raises StopIteration.
In a generator, each yield returns one value and pauses the function until the next next() call. Because generators compute values only when asked, they use almost no memory, even for very long or endless sequences. For example, a generator can read a large CSV export row by row and pass each row to the import code, so the whole file never sits in memory.
def fibonacci():
a, b = 0, 1
while True:
yield b
a, b = b, a + b
fib = fibonacci()
first = next(fib) # 1
second = next(fib) # 1
third = next(fib) # 2
fourth = next(fib) # 3
fifth = next(fib) # 5
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
values = list(Countdown(3)) # [3, 2, 1]
it = iter([1])
first = next(it) # 1
second = next(it) # StopIteration:
We can loop over a generator or an iterator only once. Every generator is an iterator, but not every iterator is a generator.
8.8. What Does the with Statement Do?
The with statement sets something up before a block and always cleans up after it, even when the block raises an exception. The object that does the setup and cleanup is called a context manager, and files, locks, database connections and ThreadPoolExecutor are all context managers. A class becomes a context manager by defining enter() and exit(), and the contextlib.contextmanager decorator makes one from a generator.
from contextlib import contextmanager
@contextmanager
def opened(name):
print("open", name)
try:
yield name.upper()
finally:
print("close", name)
with opened("report") as r:
print("using", r)
open report
using REPORT
close report
The code before yield is the setup, and the finally part is the cleanup. The yielded value goes to the as variable.
8.9. What Is the Difference Between a Shallow Copy and a Deep Copy?
A shallow copy copies only the outer object and shares the nested objects with the original, whereas a deep copy copies the nested objects too, down through every level. The difference shows only with nested mutable objects, such as a list of lists. The methods list.copy() and dict.copy(), the slice lst[:] and the function copy.copy() make shallow copies, whereas copy.deepcopy() makes deep copies.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original[0].append(99)
print(shallow) # [[1, 2, 99], [3, 4]]
print(deep) # [[1, 2], [3, 4]]
same_list = shallow is original # False
shared_inner = shallow[0] is original[0] # True
| Shallow copy | Deep copy | |
|---|---|---|
| Outer object | New | New |
| Nested objects | Shared with the original | Copied |
| A change to a nested list in the original | Visible in the copy | Not visible |
| Speed and memory | Fast, small | Slower, more memory |
| How | copy.copy(x), x.copy(), x[:] | copy.deepcopy(x) |
Plain assignment (b = a) copies nothing, because b is only a second name for the same object. For example, when we copy a default settings dict with nested lists to change one user’s options, a shallow copy also changes the nested lists of the defaults.
8.10. What Are Pickling and Unpickling?
Pickling saves a Python object as bytes, and unpickling turns those bytes back into the object. The general names for these steps are serialization and deserialization. The pickle module handles almost any Python object, including our own classes, and we use it for caches and for sending objects between our own processes.
import pickle
person = {"name": "John", "age": 30, "skills": ["python", "sql"]}
with open("person.pkl", "wb") as f:
pickle.dump(person, f)
with open("person.pkl", "rb") as f:
loaded = pickle.load(f) # loaded = {'name': 'John', 'age': 30, 'skills': ['python', 'sql']}
equal = loaded == person # True
same = loaded is person # False
Never unpickle data from an untrusted source, because a pickle can run any code while it loads. For data exchange between systems, we read and write JSON instead.
9. NumPy and Pandas Interview Questions
Data and backend roles often add questions about NumPy and pandas. NumPy gives us fast arrays of numbers, and pandas gives us tables called DataFrames. Both are third-party packages (pip install numpy pandas), and the examples use their documented current APIs.
9.1. What Is the Difference Between NumPy and SciPy?
NumPy gives us fast arrays and basic math on them, and SciPy builds on NumPy to add scientific algorithms. The NumPy array type is called ndarray, and SciPy depends on NumPy, not the other way round.
| NumPy | SciPy | |
|---|---|---|
| Core | ndarray: fast multi-dimensional arrays | Algorithms that work on NumPy arrays |
| Math | Element-wise math, basic linear algebra, random numbers, FFT | Optimization, integration, interpolation, signal processing, sparse matrices |
| Statistics | Mean, median, percentiles | Probability distributions, hypothesis tests |
| Typical use | Any numeric code. Base of pandas and scikit-learn | Engineering and scientific computing |
NumPy: arrays, vectorized operations, broadcasting
|
+--> SciPy: algorithms built around those arrays
(optimization, integration, statistics, ...)
The diagram shows the relationship; it does not mean every SciPy function returns a NumPy array or that NumPy requires SciPy.
9.2. How Do You Get the Indices of the N Largest Values in a NumPy Array?
We sort the indices with np.argsort() and take the last N. The np.argsort() function returns the indices that would sort the array, from the smallest value to the largest, so the last N indices belong to the N largest values. For a large array, np.argpartition() is faster, because it does not sort the whole array; it only splits the array around the N-th largest value. For example, a shop dashboard that shows the 3 best-selling products of the day needs only their positions in the sales array.
import numpy as np
arr = np.array([10, 5, 8, 20, 9])
n = 3
order = np.argsort(arr) # array([1, 2, 4, 0, 3])
top = np.argsort(arr)[-n:] # array([4, 0, 3])
top_first = np.argsort(arr)[-n:][::-1] # array([3, 0, 4])
idx = np.argpartition(arr, -n)[-n:]
top_fast = idx[np.argsort(arr[idx])][::-1] # array([3, 0, 4])
largest = arr[[3, 0, 4]] # array([20, 10, 9])
The argsort() function sorts from small to large, so we reverse the last N indices to list the largest value first. Index 3 holds 20, index 0 holds 10 and index 4 holds 9.
9.3. What Is the Easiest Way to Calculate Percentiles in Python?
We call np.percentile(data, q) with q between 0 and 100. When the percentile falls between two data points, the method argument chooses how to pick a value there, and the default is “linear”. For example, an API team reports the 90th percentile of response times, because the average hides the slow requests.
The interpolation argument is no longer supported by current NumPy. So interpolation=”nearest” from older answers raises TypeError. Without NumPy, statistics.quantiles() from the standard library does the same job.
import numpy as np
import statistics
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
p90 = np.percentile(data, 90) # np.float64(9.1)
p90_nearest = np.percentile(data, 90, method="nearest") # np.int64(9)
p90_old = np.percentile(data, 90, interpolation="nearest") # TypeError: percentile() got an unexpected keyword argument 'interpolation'
p90_stdlib = statistics.quantiles(data, n=10)[-1] # 9.9
The standard library uses a different default method, so for the same data its 90th percentile (9.9) differs from NumPy’s (9.1).
9.4. How Do You Combine Different Pandas DataFrames?
Pandas has three tools for combining DataFrames, and they differ in how they line up the rows.
- The pd.concat([df1, df2]) function stacks DataFrames on top of each other, or side by side with axis=1.
- The pd.merge(df1, df2, on=”col”) function joins rows on common columns, like a SQL join. The how argument chooses “inner”, “left”, “right” or “outer”, and left_on and right_on handle columns with different names.
- The df1.join(df2) method joins on the index, the row labels of the DataFrame.
import pandas as pd
prices = pd.DataFrame({"fruit": ["apple", "banana"], "price": [5, 3]})
more = pd.DataFrame({"fruit": ["cherry"], "price": [7]})
stock = pd.DataFrame({"fruit": ["apple", "cherry"], "qty": [10, 4]})
all_prices = pd.concat([prices, more], ignore_index=True)
print(all_prices)
print(pd.merge(all_prices, stock, on="fruit", how="left"))
fruit price
0 apple 5
1 banana 3
2 cherry 7
fruit price qty
0 apple 5 10.0
1 banana 3 NaN
2 cherry 7 4.0
The left join keeps banana, which has no stock row, so pandas fills its qty with NaN, the marker for a missing value. Because NaN is a float, the whole column turned into floats.
9.5. How Do You Get the Items of Series A That Are Not in Series B?
We keep the items of A for which isin(B) is false. The isin() method marks each item of A that also appears in B with True, and the ~ operator flips every True to False and every False to True.
import pandas as pd
series_a = pd.Series([1, 2, 3, 4, 5])
series_b = pd.Series([3, 4, 5, 6, 7])
print(series_a[~series_a.isin(series_b)])
0 1
1 2
dtype: int64
9.6. How Do You Get the Items That Are Not Common to Series A and B?
The items that are in only one of the two Series form the symmetric difference. In pandas, we take the union of both Series and remove the common items, and plain Python sets give the same result with the ^ operator.
import numpy as np
import pandas as pd
series_a = pd.Series([1, 2, 3, 4, 5])
series_b = pd.Series([3, 4, 5, 6, 7])
union = pd.Series(np.union1d(series_a, series_b))
common = np.intersect1d(series_a, series_b)
not_common = union[~union.isin(common)].tolist() # [1, 2, 6, 7]
sym_diff = set(series_a.tolist()) ^ set(series_b.tolist()) # {1, 2, 6, 7}
We call tolist() before set() because, a set of NumPy values may print as np.int64(1) and so on, whereas tolist() turns the values into plain Python ints.
9.7. Can Pandas Recognize Dates While Importing Data?
Yes, pd.read_csv(…, parse_dates=[“col”]) turns a column into dates while reading the file. The pd.to_datetime() function converts strings that are already in a DataFrame, and pd.date_range() generates a sequence of dates.
With date columns, we can filter by date, group by month and resample, which means grouping rows by a time period, such as a week. Without pandas, the csv module reads and writes CSV files but returns every value as a string, dates included.
import pandas as pd
df = pd.read_csv("sales.csv", parse_dates=["day"])
day_type = str(df["day"].dtype) # 'datetime64[us]'
day_names = df["day"].dt.day_name().tolist() # ['Thursday', 'Friday']
day = pd.to_datetime("04-10-2026", format="%d-%m-%Y") # Timestamp('2026-10-04 00:00:00')
week = len(pd.date_range("2026-10-01", periods=7, freq="D")) # 7
9.8. How Do You Read a Publicly Shared CSV File From Google Drive?
This direct-download approach is only for a file intended to be public. Do not broaden access to private data merely to make read_csv work. Use an authenticated download for restricted files, then pass the resulting data to the parser.
def drive_csv_url(share_link):
file_id = share_link.split("/d/")[1].split("/")[0]
return f"https://drive.google.com/uc?export=download&id={file_id}"
url = drive_csv_url("https://drive.google.com/file/d/1AbC9xYz/view?usp=sharing") # 'https://drive.google.com/uc?export=download&id=1AbC9xYz'
Then df = pd.read_csv(drive_csv_url(link)) reads the file like any other CSV URL. For a Google Sheet, the export URL is https://docs.google.com/spreadsheets/d/SHEET_ID/export?format=csv.
10. Python Coding Interview Questions
Coding rounds test whether we can turn a small problem into clean code, and interviewers also ask how much time and memory the code needs. For each problem, we give a readable solution first and then mention the built-in shortcut, because interviewers often ask for both.
10.1. Write a Program to How would you produce the Fibonacci series?
In the Fibonacci series, each number is the sum of the two numbers before it, starting with 0 and 1. A loop with two variables builds the series in O(n) time, which means that the work grows in step with n.
def fibonacci(n):
a, b = 0, 1
result = []
for _ in range(n):
result.append(a)
a, b = b, a + b
return result
ten = fibonacci(10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
one = fibonacci(1) # [0]
empty = fibonacci(0) # []
Interviewers often ask for the recursive version next. Without a cache, the recursive version computes the same values again and again, so the work grows exponentially. The @functools.cache decorator removes the repeated work by storing each result once.
from functools import cache
@cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
fib_10 = fib(10) # 55
fib_50 = fib(50) # 12586269025
10.2. Write a Program to How would you check whether a number is prime?
A prime is a whole number greater than 1 that only 1 and itself divide evenly. We test divisors only up to the square root of the number, because a larger divisor always pairs with a smaller one that we have already tested. The math.isqrt() function returns that square root as an exact integer.
import math
def is_prime(num):
if num < 2:
return False
if num % 2 == 0:
return num == 2
for i in range(3, math.isqrt(num) + 1, 2):
if num % i == 0:
return False
return True
primes = [n for n in range(20) if is_prime(n)] # [2, 3, 5, 7, 11, 13, 17, 19]
prime_97 = is_prime(97) # True
prime_91 = is_prime(91) # False
10.3. Write a Program to How would you check whether a sequence is a palindrome?
A palindrome reads the same backward and forward. We lowercase the text, keep only letters and digits with str.isalnum, and compare the result with its reverse, [::-1].
def is_palindrome(text):
cleaned = "".join(filter(str.isalnum, text.lower()))
return cleaned == cleaned[::-1]
sentence = is_palindrome("A man, a plan, a canal: Panama") # True
word = is_palindrome("hello") # False
seq = [1, 2, 1]
seq_palindrome = seq == seq[::-1] # True
For a list, the same comparison seq == seq[::-1] works without the cleaning step, as the last line shows.
10.4. How would you count uppercase letters in a file too large for memory?
We read the file in chunks of a fixed size, so only one chunk is in memory at a time. The str.isupper() method tells us whether a character is a capital letter, and it also counts accented capitals, such as the “E” with an acute accent.
def count_caps(path, chunk_size=1024 * 1024):
count = 0
with open(path, encoding="utf-8") as f:
while chunk := f.read(chunk_size):
count += sum(1 for ch in chunk if ch.isupper())
return count
caps = count_caps("big.txt") # 15
The test file big.txt holds the lines “Hello World” and “Python Is Fun” three times, and each pair of lines has 5 capitals.
10.5. How would you sort a numerical dataset?
In real code, we call sorted(nums). In an interview, we may have to write a sort by hand, and bubble sort is the shortest to explain.
Bubble sort swaps neighbors that are in the wrong order, so each pass moves the largest remaining value to the end, and it stops when a full pass makes no swap. Bubble sort takes O(n^2) time, while sorted() takes O(n log n).
def bubble_sort(nums):
arr = list(nums)
n = len(arr)
for i in range(n):
swapped = False
for j in range(n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped:
break
return arr
by_hand = bubble_sort([5, 2, 9, 1, 5]) # [1, 2, 5, 5, 9]
built_in = sorted([5, 2, 9, 1, 5]) # [1, 2, 5, 5, 9]
10.6. How would you convert a date between the shown formats?
We read (parse) the date with datetime.strptime() and write it in the new format with strftime(). Parsing also checks the date, so an impossible date, such as February 30, raises ValueError.
from datetime import datetime
def convert_date(date_str):
return datetime.strptime(date_str, "%Y-%m-%d").strftime("%d-%m-%Y")
converted = convert_date("2026-10-04") # '04-10-2026'
invalid = convert_date("2026-02-30") # ValueError: day 30 must be in range 1..28 for month 2 in year 2026
10.7. How would you match an a followed by four to eight b characters?
The pattern ab{4,8} means “an a, then 4 to 8 b characters”. The re.fullmatch() function requires the whole string to match the pattern. With re.search() instead, “abbbbbbbbb” (nine b’s) would also match, because re.search() finds an a with eight b’s inside the string and ignores the rest.
import re
def match_string(text):
return re.fullmatch(r"ab{4,8}", text) is not None
four_bs = match_string("abbbb") # True
three_bs = match_string("abbb") # False
nine_bs = match_string("abbbbbbbbb") # False
prefixed = match_string("xabbbb") # False
10.8. How would you check whether all numbers in a sequence are unique?
A set drops duplicates, so the numbers are unique when the set has the same length as the sequence. The loop version stops at the first duplicate, which saves work for long sequences.
def is_unique(seq):
seen = set()
for num in seq:
if num in seen:
return False
seen.add(num)
return True
unique = is_unique([1, 2, 3, 4]) # True
repeated = is_unique([1, 2, 3, 2]) # False
by_set = len(set([1, 2, 3, 2])) == 4 # False
To drop the duplicates instead, we remove duplicates and keep the original order.
10.9. How would you count characters in a text file?
The collections.Counter class counts how often each item of an iterable appears. A file object gives us one line at a time, and a line is an iterable of characters, so we pass each line to Counter. Counter is a dict subclass, and most_common(n) returns the most frequent items.
from collections import Counter
def count_chars(path):
counts = Counter()
with open(path, encoding="utf-8") as f:
for line in f:
counts.update(line)
return counts
counts = count_chars("notes.txt")
top_three = counts.most_common(3) # [('l', 4), (' ', 2), ('\n', 2)]
capital_b = counts["B"] # 1
letter_z = counts["z"] # 0
The file notes.txt contains “Buy milk” and “Call Alex”. A missing key returns 0 instead of raising KeyError.
10.10. How would you find pairs whose sum equals a target?
The nested loops examine index pairs and take quadratic time. The set-based example can find distinct value pairs in expected linear scan time, but it does not enumerate every combination of duplicate indices. Clarify whether the task wants indices, values or unique pairs.
def find_pairs_brute(nums, target):
pairs = []
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
pairs.append((nums[i], nums[j]))
return pairs
def find_pairs(nums, target):
seen, pairs = set(), []
for n in nums:
if target - n in seen:
pairs.append((target - n, n))
seen.add(n)
return pairs
slow_pairs = find_pairs_brute([2, 7, 4, 5, 3], 9) # [(2, 7), (4, 5)]
fast_pairs = find_pairs([2, 7, 4, 5, 3], 9) # [(2, 7), (4, 5)]
10.11. How would you sum a list of numbers?
sum adds a numeric iterable. Its floating-point algorithm can differ from a simple loop, and math.fsum is designed for accurate floating-point summation. Neither should be treated as a universal exact-arithmetic guarantee; use an appropriate numeric type when decimal exactness is required.
import math
def sum_list(numbers):
total = 0
for num in numbers:
total += num
return total
loop_total = sum_list([1, 2, 3, 4]) # 10
builtin_total = sum([1, 2, 3, 4]) # 10
loop_floats = sum_list([0.1] * 10) # 0.9999999999999999
builtin_floats = sum([0.1] * 10) # 1.0
exact_floats = math.fsum([0.1] * 10) # 1.0
10.12. How would you merge dictionaries and add values of shared keys?
We copy the first dict and then add each value of the second dict, and get(key, 0) returns 0 for keys that are not there yet. The | operator merges two dicts too, but for a common key it does not add the values. Instead, the value from the right side replaces the left one.
def merge_sum(d1, d2):
result = dict(d1)
for key, value in d2.items():
result[key] = result.get(key, 0) + value
return result
d1 = {"apple": 5, "banana": 3}
d2 = {"banana": 2, "cherry": 7}
summed = merge_sum(d1, d2) # {'apple': 5, 'banana': 5, 'cherry': 7}
merged = d1 | d2 # {'apple': 5, 'banana': 2, 'cherry': 7}
10.13. How would you add two positive integers without the + operator?
We add the numbers bit by bit, with XOR for the sum and AND for the carry. XOR (^) adds the bits without the carry, and AND (&), shifted left by one place (<< 1), gives the carry. We repeat until no carry is left.
def add(a, b):
while b != 0:
carry = (a & b) << 1
a = a ^ b
b = carry
return a
total1 = add(5, 7) # 12
total2 = add(1, 0) # 1
total3 = add(255, 1) # 256
Take 5 (101) and 7 (111). XOR gives 010, the carry is 1010, and the loop continues until the carry is 0.
The loop works for positive numbers only, because Python integers have no fixed width, so a negative number never runs out of carry bits.
The call sum((a, b)) also avoids the + sign, but sum() adds with + internally, so most interviewers do not accept that answer.
10.14. How would you print the table of two with a while loop?
A while loop runs as long as its condition is true. So we must increase the counter inside the loop ourselves, otherwise the loop never ends.
i = 1
while i <= 10:
print(f"2 x {i} = {2 * i}")
i += 1
2 x 1 = 2
2 x 2 = 4
2 x 3 = 6
2 x 4 = 8
2 x 5 = 10
2 x 6 = 12
2 x 7 = 14
2 x 8 = 16
2 x 9 = 18
2 x 10 = 20
10.15. How would you print whether a value is even or odd?
A number is even when num % 2 is 0. The modulo operator % returns the remainder of a division, and in Python it also works for negative numbers, because the result takes the sign of the divisor.
def even_or_odd(num):
print(f"{num} is {'even' if num % 2 == 0 else 'odd'}")
even_or_odd(4)
even_or_odd(7)
even_or_odd(-3)
4 is even
7 is odd
-3 is odd
10.16. How would you find the minimum and maximum values in a tuple?
The built-in min() and max() functions work on any iterable, including tuples. Both accept a key function, such as len, and a default value for empty input.
tup = (3, 5, 1, 9, 2)
smallest = min(tup) # 1
largest = max(tup) # 9
longest = max(("kiwi", "banana", "fig"), key=len) # 'banana'
fallback = min((), default=0) # 0
10.17. How Do You Print a Star Pattern Without a Newline or Space?
We pass end=”” and sep=”” to print(). By default, print() ends with a newline and puts a space between its arguments, so end=”” turns off the newline and sep=”” turns off the space. The first loop prints 5 rows of 5 stars as one line, because every print() call ends without a newline.
for i in range(5):
for j in range(5):
print("*", end="")
print()
*************************
for i in range(1, 5):
print("*" * i)
*
**
***
****
11. What should you practice next?
Most Python interview questions test a few core rules.
- Which types are mutable.
- How names refer to objects.
- How scope works.
- How Python runs code and frees memory.
Use the examples to explain object sharing, scope, iteration and error handling in your own words. Then practice role-specific code and discuss its edge cases and complexity. A small correct explanation is more useful than memorizing a long answer verbatim.
12. Which primary sources explain these rules?
The official Python documentation and PEPs cover each rule in more depth.
- The Python Tutorial
- Status of Python versions
- PEP 8: Style Guide for Python Code
- PEP 703: Making the Global Interpreter Lock Optional
- PEP 779: Criteria for supported status for free-threaded Python
- Python data model
- Python glossary
- copy module
- numpy.percentile
Happy Learning !!