Imagine your Computer Science teacher gives you this task: "Write a program that checks whether a student passed, and print their grade." You write it, test it on one student's marks, and it works perfectly. Then the teacher says, "Now do this for all 40 students in the class." If the only tools you have are variables, if-else, and print(), you are stuck copy-pasting the same six lines forty times, changing one number each time. One typo in student 27's block and the whole thing silently gives a wrong grade — and you won't even know which copy broke.
This is the exact wall that every programmer hits, and Python gives you two tools to knock it down: functions, which let you write a block of logic once and reuse it with different inputs, and lists, which let you store many related values under one name instead of forty separate variables. Together with a third tool, dictionaries, they turn "one calculation" into "a program that scales to a whole class, a whole school, or a whole database." This chapter builds all three from the ground up, shows exactly how they interact — including a mistake almost every beginner makes — and ends with a working mini class-marks analyzer.
1. The copy-paste problem, made concrete
Here is the kind of code you'd write without functions, for just two students:
marks1 = 45
if marks1 >= 33:
print("Student 1: PASS")
else:
print("Student 1: FAIL")
marks2 = 28
if marks2 >= 33:
print("Student 2: PASS")
else:
print("Student 2: FAIL")
Tracing this: marks1 is 45, and 45 >= 33 is True, so line 3 prints Student 1: PASS. Then marks2 is 28, and 28 >= 33 is False, so the else branch prints Student 2: FAIL. Output:
Student 1: PASS
Student 2: FAIL
The logic — "check if marks meet the pass mark" — is identical in both blocks. Only the number changed. Anything that repeats like this in code is a warning sign: you should be writing it once and calling it many times. That is precisely what a function is for.
2. Functions: writing the logic once
A function is a named, reusable block of code. You define it once with the def keyword, give it a name and a list of parameters (placeholders for values it will receive), and then you can call it as many times as you like with different values, called arguments.
def show_result(marks):
if marks >= 33:
print("PASS")
else:
print("FAIL")
show_result(45)
result = show_result(45)
print(result)
Trace this carefully, because it exposes a mistake almost every Python beginner makes. The call show_result(45) runs the function: inside it, marks is bound to 45, the condition is True, and print("PASS") runs — so PASS appears on screen. That's a side effect: the function displayed something, but it did not hand any value back to the line that called it.
The next call is the trap. result = show_result(45) calls the function again — so it prints PASS a second time, as a side effect — and then tries to store whatever the function returns into result. But this function never uses the word return anywhere. In Python, a function with no return statement automatically hands back a special empty value called None. So result is set to None, and the final print(result) prints None. Full output:
PASS
PASS
None
Common misconception, corrected directly: many students believe print() inside a function is the same as "returning a result." It is not. print() only displays text on the screen for a human to read — it does not give the calling code anything to work with. return is different: it hands a value back to wherever the function was called, so that value can be stored in a variable, compared, passed into another function, or used in a calculation. If you only ever print inside a function, the result vanishes the moment it's displayed — the rest of your program can never touch it. Fix the function by returning instead:
def get_result(marks):
if marks >= 33:
return "PASS"
else:
return "FAIL"
result = get_result(45)
print(result)
print("Status:", get_result(28))
Trace: get_result(45) evaluates 45 >= 33 as True and returns the string "PASS" — nothing is printed by the function itself. That returned value is stored in result, and the next line prints it. The following line calls get_result(28) directly inside a print() call; 28 >= 33 is False, so it returns "FAIL", and print displays it alongside the label. Output:
PASS
Status: FAIL
Now the result is data your program can reuse — exactly what you need for grading 40 students instead of announcing one.
3. Default parameters
Functions can also have parameters with a built-in fallback value, called a default parameter. You supply it only when you want to override the default. Let's build a small grading function with our own made-up grade bands (not the official CBSE scale — just a clear example to practice with) and a pass mark that defaults to 33 but can be changed:
def get_grade(marks, pass_mark=33):
if marks < pass_mark:
return "FAIL"
elif marks >= 90:
return "A1"
elif marks >= 75:
return "A2"
elif marks >= 60:
return "B1"
elif marks >= 45:
return "B2"
else:
return "C1"
print(get_grade(82))
print(get_grade(20, pass_mark=25))
Trace: get_grade(82) is called with only one argument, so pass_mark uses its default, 33. 82 < 33 is False, 82 >= 90 is False, 82 >= 75 is True — so it returns "A2" and Python stops checking the remaining branches (an elif chain always takes only the first matching branch). Then get_grade(20, pass_mark=25) explicitly sets pass_mark to 25; since 20 < 25 is True, it immediately returns "FAIL". Output:
A2
FAIL
4. Lists: many values, one name
Now for the second half of the problem: instead of marks1, marks2, marks3, ... you want one container holding every student's marks. That container is a list — an ordered, changeable sequence of values written inside square brackets.
marks = [78, 45, 90, 32, 65]
print(marks[0])
print(marks[2])
print(marks[-1])
print(marks[-2])
Python numbers list positions (called indices) starting from 0, not 1 — the first item is marks[0], not marks[1]. This trips up almost everyone at first, so lock it in: with five items, the valid positive indices are 0, 1, 2, 3, 4. Python also lets you count backward from the end using negative indices: -1 is always the last item, -2 the second-last, and so on — useful when you don't know how long a list is but want its last element.
Trace the code above against the diagram: marks[0] is the first box, 78. marks[2] is the third box, 90. marks[-1] is the last box, 65. marks[-2] is the second-from-last box, 32. Output:
78
90
65
32
5. Slicing: grabbing a chunk of a list
You can pull out a whole range of items at once using slicing, written list[start:stop]. The item at start is included; the item at stop is not — this is the single most common source of off-by-one bugs with lists, so treat "stop is excluded" as a rule to memorise, not guess.
print(marks[1:3])
print(marks[:2])
print(marks[3:])
marks[1:3] starts at index 1 and stops before index 3, so it grabs indices 1 and 2: [45, 90] — exactly what the highlighted region in the diagram shows. marks[:2] omits the start, which defaults to the beginning of the list, and stops before index 2: [78, 45]. marks[3:] omits the stop, which defaults to the end of the list, starting from index 3: [32, 65]. Output:
[45, 90]
[78, 45]
[32, 65]
6. Lists are mutable — and so are the methods that work on them
Unlike a number or a string, a list can be changed in place after it's created — this property is called mutability. You can overwrite one item by assigning to its index, and Python gives you built-in methods to grow, sort, and summarise a list:
marks[0] = 80
print(marks)
marks.append(88)
print(marks)
marks.sort()
print(marks)
print(len(marks))
print(sum(marks))
print(max(marks))
print(min(marks))
Trace step by step. marks[0] = 80 overwrites the first box: the list is now [80, 45, 90, 32, 65]. marks.append(88) adds 88 to the end: [80, 45, 90, 32, 65, 88]. marks.sort() rearranges the list in place in ascending order: [32, 45, 65, 80, 88, 90] — note that sort() changes the list itself rather than returning a new sorted copy. len(marks) counts the items: 6. sum(marks) adds them: 32+45+65+80+88+90 = 400. max(marks) is 90, min(marks) is 32. Output:
[80, 45, 90, 32, 65]
[80, 45, 90, 32, 65, 88]
[32, 45, 65, 80, 88, 90]
6
400
90
32
To walk through every item in a list, use a for loop directly on it — no index needed if you just want the values:
for m in marks:
print(m, end=" ")
This prints each value from the now-sorted list separated by a space (because end=" " replaces the usual newline after print): 32 45 65 80 88 90 . But sometimes you need the position too — for example, to label each item. Then loop over range(len(marks)) and use the index to reach into the list:
for i in range(len(marks)):
print(f"Position {i}: {marks[i]}")
range(len(marks)) produces 0, 1, 2, 3, 4, 5 since the list has 6 items, and marks[i] fetches the value at each position. Output:
Position 0: 32
Position 1: 45
Position 2: 65
Position 3: 80
Position 4: 88
Position 5: 90
7. The misconception that breaks real programs: how lists behave inside functions
This is the part where functions and lists collide, and where even confident students get surprised. Consider two very similar-looking functions:
def add_bonus(marks_list):
marks_list.append(5)
def add_bonus_number(marks):
marks = marks + 5
scores = [70, 60]
add_bonus(scores)
print(scores)
total = 70
add_bonus_number(total)
print(total)
Most beginners expect both functions to behave the same way — after all, both just "add 5" inside a function and don't return anything. They don't. Trace carefully.
add_bonus(scores): when a list is passed into a function, the parameter marks_list does not receive a copy — it becomes another name pointing at the exact same list object in memory. So marks_list.append(5) modifies that one shared list directly. When the function ends, scores — the original name — sees the change too, because there was only ever one list. Output: [70, 60, 5].
add_bonus_number(total): numbers are not mutable, so nothing can be changed "in place" the way a list can. When you write total as the argument, the parameter marks is bound to a copy of the value 70. The line marks = marks + 5 doesn't change anything in existing memory — it computes 75 and rebinds the local name marks to point at this new value, while the outer total was never touched and still points at 70. Output: 70, unchanged.
[70, 60, 5]
70
The rule to remember: passing a list to a function lets that function change the original list's contents (via methods like append, sort, or index assignment), but reassigning a parameter — for lists, numbers, or anything else — only ever changes the local copy of the reference, never the caller's variable. This single distinction is responsible for a huge share of "why did my data change when I didn't expect it to" bugs in real Python programs, so it is worth re-reading this trace until both outputs make sense.
8. Dictionaries: labelling values instead of just ordering them
A list is great when position tells you what something means — marks[0] is "the first student's marks." But often you want to label a value by name rather than position: "this student's name" and "this student's marks" belong together. That's what a dictionary is for — a collection of key: value pairs, written in curly braces, where you look things up by key instead of by numeric index.
student1 = {"name": "Aarav", "marks": 78}
print(student1["name"])
print(student1["marks"])
student1["name"] looks up the value stored under the key "name", and student1["marks"] looks up the value under "marks". Output:
Aarav
78
9. Putting it all together: a class marks analyzer
Now combine everything — functions, lists, and dictionaries — into one working program that solves the exact problem we started with, but for an entire class at once instead of one copy-pasted block per student. A list can hold dictionaries, one per student, and a function can loop through that list:
students = [
{"name": "Aarav", "marks": 78},
{"name": "Diya", "marks": 32},
{"name": "Kabir", "marks": 90},
{"name": "Meher", "marks": 45}
]
def get_grade(marks, pass_mark=33):
if marks < pass_mark:
return "FAIL"
elif marks >= 90:
return "A1"
elif marks >= 75:
return "A2"
elif marks >= 60:
return "B1"
elif marks >= 45:
return "B2"
else:
return "C1"
def class_average(students_list):
total = 0
for s in students_list:
total = total + s["marks"]
return total / len(students_list)
for s in students:
grade = get_grade(s["marks"])
print(f"{s['name']}: {s['marks']} marks -> Grade {grade}")
print(f"Class average: {class_average(students):.2f}")
Trace the loop first. For Aarav, get_grade(78) checks 78 < 33 (False), 78 >= 90 (False), 78 >= 75 (True) — returns "A2". For Diya, get_grade(32) checks 32 < 33 (True) immediately — returns "FAIL". For Kabir, get_grade(90) checks 90 < 33 (False), 90 >= 90 (True) — returns "A1". For Meher, get_grade(45) checks 45 < 33 (False), 45 >= 90 (False), 45 >= 75 (False), 45 >= 60 (False), 45 >= 45 (True) — returns "B2".
Now trace class_average(students): total starts at 0, and the loop adds each student's marks in order: 0+78=78, 78+32=110, 110+90=200, 200+45=245. After the loop, total is 245 and len(students_list) is 4, so the function returns 245/4 = 61.25. The :.2f inside the f-string formats that number to exactly two decimal places. Full output:
Aarav: 78 marks -> Grade A2
Diya: 32 marks -> Grade FAIL
Kabir: 90 marks -> Grade A1
Meher: 45 marks -> Grade B2
Class average: 61.25
Notice what changed from section 1: adding a fifth or fortieth student now takes one extra dictionary in the students list — zero changes to the grading logic, zero changes to the average calculation, and zero risk of a copy-paste typo in a duplicated block. That is the entire point of advanced Python: the same three ideas — a function that encapsulates logic, a list that scales storage, and a dictionary that labels related values — are exactly what powers real systems, from a spreadsheet macro to the backend that ranks entrance-exam results for lakhs of students.
10. Common mistakes to watch for
- Confusing
printwithreturn. A function that only prints has nothing to give back to the code that called it — trying to store its result gives youNone, as traced in section 2. - Off-by-one errors in slicing.
marks[1:3]gives you 2 items, not 3, because the stop index is excluded. When in doubt, count: stop minus start equals how many items you get. - Assuming numbers and lists behave the same way inside functions. They don't. A list passed into a function can be changed permanently through its methods; a number passed in cannot be changed by reassigning the parameter, as traced in section 7.
- Forgetting that
sort()changes the list in place and does not return the sorted list —marks = marks.sort()is a classic bug that silently setsmarkstoNone, for the same reason as the first bullet.
Try it yourself
Work out each answer by tracing the code by hand before running it — that discipline is what actually builds programming skill.
- What does
print(get_grade(59))output, using theget_gradefunction from section 3? - Given
nums = [12, 7, 33, 8, 19], what doesnums[2:4]evaluate to, and what doesnums[-3]evaluate to? - Write a function
count_pass(students_list)that takes a list of student dictionaries (each with a"marks"key) and returns how many students scored 33 or above. Use a loop and a counter variable, and return the counter — do not print it. - A classmate writes
def double(n): n = n * 2and then callsdouble(x)expectingxto become twice its value. It doesn't work. Explain why, using the same reasoning as section 7. - Given
marks.sort()from section 6, what wouldprint(marks.sort())print, and why? (Hint: check whatsort()returns, not what it does to the list.)
Answers to check your work: (1) 59 >= 33, 59 not >= 90/75/60, so >= 45 is True: output is B2. (2) nums[2:4] is [33, 8]; nums[-3] counts back three from the end and is 33. (3) def count_pass(students_list): count = 0; for s in students_list: if s["marks"] >= 33: count = count + 1; return count — the counter must be initialised to 0 before the loop and returned, not printed, so the calling code can use the number. (4) n is a local copy bound to the value of x; reassigning n inside the function only changes what the local name n points to, never the caller's x, exactly as with add_bonus_number in section 7. (5) sort() always returns None — it changes the list in place instead of returning a new one — so print(marks.sort()) prints None, even though marks itself is now sorted.
Summary
- A function, defined with
def name(parameters):, lets you write logic once and reuse it with different arguments — avoiding copy-paste bugs. print()only displays a value;returnhands a value back to the caller so it can be stored, compared, or reused. A function withoutreturngives backNone.- Default parameters (
def f(x, y=default):) let a function be called with fewer arguments while still allowing full control when needed. - A list stores an ordered, changeable sequence of values. Indices start at 0; negative indices count from the end, with
-1as the last item. - Slicing (
list[start:stop]) includes the start index and excludes the stop index. - Lists are mutable: index assignment and methods like
append()andsort()change the list itself, in place.sort()returnsNone, not a new list. - When a list is passed to a function, the function can permanently change its contents, because the parameter refers to the same list object — this is different from passing a number, where reassigning the parameter never affects the caller's variable.
- A dictionary stores
key: valuepairs, letting you look up a value by a meaningful label instead of a numeric position. - Combining a list of dictionaries with functions lets one small program — grading logic plus an averaging function — scale from one student to an entire class without rewriting any logic.