Python Programming
40 subscribers
5 photos
2 links
🐍 Python Programming | Code. Learn. Grow.

Welcome to Python Programming — your go-to Telegram community for mastering Python, from basics to advanced!
Download Telegram
📘 Day 4: Strings: indexing, slicing, and common string methods

Strings in Python are immutable sequences of Unicode characters. Think of a string as a row of boxes, each holding one character, with a fixed order that never changes once the string is created. Because of this ordering, Python lets you address individual characters and extract sub‑strings using indexing and slicing.

Indexing
- Every character has a zero‑based position: the first character is index 0, the second is 1, and so on.
- Python also supports negative indices: -1 refers to the last character, -2 to the one before that, etc.
- Indexing returns a new string containing exactly one character.

Why it works: Under the hood, a string object stores its characters in a contiguous memory block. When you ask for s[i], Python calculates the memory offset base_address + i * size_of_character (handling negative i by adding the length). It then creates a new 1‑character string from that location.

Slicing
- A slice is written s[start:stop:step].
- start is inclusive, stop is exclusive. If omitted, they default to the beginning (0) and the end (len(s)), respectively.
- step controls the stride; a negative step walks the string backwards.
- The result is another string that contains the selected characters in order.

Why it works: Python builds the slice by iterating from start to stop (excluding) using the given step. For each position it fetches the character (just like indexing) and appends it to a new buffer, which becomes the sliced string.

Common string methods – these are functions bound to a string object that return a new string (the original stays unchanged). Some frequently used ones:

- .upper() – returns a copy with all letters converted to uppercase.
- .lower() – returns a copy with all letters converted to lowercase.
- .replace(old, new, count=-1) – substitutes occurrences of old with new; count limits replacements.
- .split(sep=None, maxsplit=-1) – breaks the string into a list of substrings based on a separator.
- .strip(chars=None) – removes leading and trailing characters (whitespace by default).

Each method works by scanning the original string, applying its rule, and constructing a brand‑new string object. Because strings are immutable, this design avoids accidental side‑effects.

Putting it all together – a complete example

# Demo: indexing, slicing, and three common methods
text = "Hello, World!" # original immutable string

# Indexing
first_char = text[0] # 'H' – start of the string
last_char = text[-1] # '!' – end of the string

# Slicing
sub = text[7:12] # 'World' – start at 7, stop before 12
reverse = text[::-1] # '!dlroW ,olleH' – step -1 walks backwards

# Methods
upper = text.upper() # 'HELLO, WORLD!'
replaced = text.replace("World", "Python") # 'Hello, Python!'

# Show everything
print("Original :", text)
print("First char:", first_char)
print("Last char :", last_char)
print("Slice [7:12]:", sub)
print("Reversed :", reverse)
print("Uppercase :", upper)
print("Replaced :", replaced)


(continued below 👇)
Walk‑through of the code
1. text = "Hello, World!" creates the original string object.
2. text[0] and text[-1] fetch single characters using positive and negative indices.
3. text[7:12] extracts characters from position 7 up to—but not including—position 12, producing "World".
4. text[::-1] uses a step of -1; start and stop default to the string’s bounds, so Python walks from the end to the beginning, yielding the reversed string.
5. text.upper() iterates over every character, converting alphabetic ones to uppercase, and assembles a new string.
6. text.replace("World", "Python") scans for the exact substring "World" and swaps it with "Python", leaving the rest untouched.
7. The print statements display each result, confirming that the original text never changed.

Understanding how these operations work—offset calculation for indexing, iteration for slicing, and construction of new buffers for methods—gives you a solid mental model. You’ll be able to predict edge cases (e.g., out‑of‑range indices raise IndexError, slices that exceed bounds are simply clipped) and write more efficient, bug‑free string manipulations.
What does the slice expression `s[2:5]` return when `s = "abcdef"`?
Anonymous Quiz
0%
"ab"
100%
"cde"
0%
"cdef"
0%
"bcd"
📘 Day 5: f-strings and string formatting

What are f‑strings?
In Python, formatted string literals (commonly called f‑strings) let you embed Python expressions directly inside string literals, using a minimal and readable syntax. They were introduced in Python 3.6 to replace older formatting methods (% operator and str.format) with something that feels natural to write and fast to execute.

Why do they work?
When the Python interpreter sees a string that starts with the letter f (or F), it parses the content looking for pairs of curly braces {}. Anything inside those braces is treated as a regular Python expression. At runtime, the expression is evaluated, converted to a string (using __format__ if a format spec is provided), and the result replaces the braces in the final output. Because the evaluation happens while the string is being built, you can use variables, function calls, or even complex expressions without any extra concatenation.

Basic syntax

name = "Ada"
age = 28
msg = f"Hello, {name}! You are {age} years old."


- f before the opening quote tells Python to treat this as an f‑string.
- {name} and {age} are replacement fields. The interpreter looks up the current value of those variables and injects them.
- The surrounding text (Hello, , ! You are , years old.) is left untouched.

How is it different from older methods?

% operator – needs a separate tuple or dict, can be confusing with many placeholders.
str.format() – more flexible, but still requires you to repeat variable names or positions.
f‑strings – concise, evaluated at runtime, and can include any expression.

Embedding expressions

You can place any valid Python expression inside {}:

import math
radius = 3
area_msg = f"The area of a circle with radius {radius} is {math.pi * radius ** 2:.2f}"


- math.pi * radius ** 2 computes the area.
- :.2f is a format specifier that tells Python to format the result as a floating‑point number with two decimal places.

Why the format spec works
After the expression, a colon : separates the value from its format spec. The spec follows the same rules as str.format(). Python calls the object's __format__ method, passing the spec string. For numbers, :.2f means fixed‑point notation with two digits after the decimal point.

Formatting numbers, dates, and more

from datetime import datetime

value = 1234.5678
today = datetime(2023, 9, 1, 14, 30)

info = f"""Value: {value:,.2f}
Date : {today:%Y-%m-%d %H:%M}
Hex : {int(value):#06x}
"""
print(info)


Explanation of each field:

- {value:,.2f} – adds commas as thousands separators and keeps two decimals.
- {today:%Y-%m-%d %H:%M} – uses datetime’s strftime‑style spec to format the date.
- {int(value):#06x} – converts value to an integer, then formats it as hexadecimal with a 0x prefix, padded to 6 characters.

Escaping braces
If you need a literal { or } in the output, double them:

msg = f"Set notation: {{1, 2, 3}}"
# Output: Set notation: {1, 2, 3}


Performance note
Because f‑strings are evaluated at runtime and compiled into a series of string concatenations, they are generally faster than str.format() and %‑formatting, especially when many placeholders are used.

Complete runnable example

(continued below 👇)
#!/usr/bin/env python3
"""
Demonstrates various f‑string capabilities:
- Simple variable insertion
- Expression evaluation
- Number formatting
- Date formatting
- Hexadecimal representation
- Escaping braces
"""

from datetime import datetime

def main():
# Simple variables
user = "Grace"
tasks = 5

# Expression inside f‑string
greeting = f"Hi {user}, you have {tasks * 2} pending items."

# Number with commas and two decimals
price = 1999.99
price_msg = f"Total price: ${price:,.2f}"

# Current date and time formatting
now = datetime.now()
date_msg = f"Report generated on {now:%A, %d %B %Y at %H:%M:%S}"

# Hexadecimal with prefix and zero‑padding
checksum = 0x1a3f
hex_msg = f"Checksum: {checksum:#06x}"

# Escaping braces
set_msg = f"Example set: {{'apple', 'banana', 'cherry'}}"

# Combine everything
report = "\n".join([greeting, price_msg, date_msg, hex_msg, set_msg])
print(report)

if __name__ == "__main__":
main()


Walk‑through

1. Imports – datetime is needed for date formatting.
2. Variables – user, tasks, price, checksum hold basic data.
3. greeting – {tasks * 2} multiplies tasks by 2 inside the string, demonstrating expression evaluation.
4. price_msg – {price:,.2f} inserts price with a comma as thousand separator and exactly two decimal places.
5. date_msg – {now:%A, %d %B %Y at %H:%M:%S} uses a strftime‑style spec to produce a human‑readable timestamp.
6. hex_msg – {checksum:#06x} formats checksum as a zero‑padded hexadecimal (0x1a3f).
7. set_msg – {{...}} escapes braces so the output shows literal curly braces.
8. report – "\n".join([...]) concatenates all lines with newline characters.
9. print(report) – outputs the fully formatted multi‑line string to the console.

Running this script prints something like:

Hi Grace, you have 10 pending items.
Total price: $1,999.99
Report generated on Tuesday, 12 October 2026 at 14:23:45
Checksum: 0x1a3f
Example set: {'apple', 'banana', 'cherry'}


Each line demonstrates a different facet of f‑string power, giving you a solid foundation to apply them in real projects.

Key take‑aways

- Prefix a string with f (or F) to enable inline expression evaluation.
- Use {} to insert variables, call functions, or run arithmetic directly.
- After a colon :, provide a format spec to control number, date, or custom formatting.
- Escape literal braces by doubling them ({{ or }}).
- f‑strings are concise, readable, and usually the fastest built‑in formatting option.
Which part of an f‑string specifies how a value should be displayed, such as number of decimal places?
Anonymous Quiz
0%
The expression before the colon
50%
The format spec after the colon
0%
The variable name inside braces
50%
The leading 'f' character
📘 Day 6: Numbers and arithmetic operators

Python treats every numeric value as an object that knows its own type and how to behave with operators. Understanding the why behind numbers and arithmetic operators helps you predict results, avoid bugs, and write clearer code.

Basic numeric types
- int – whole numbers, unlimited precision (e.g., -3, 0, 42).
- float – real numbers stored in double‑precision binary format (e.g., 3.14, -0.001).
- complex – numbers with a real and an imaginary part (2 + 3j).

Each type implements the standard arithmetic operators (+, -, *, /, //, %, **). When you combine two numbers, Python follows a set of rules:

1. Type promotion – if the operands differ, the smaller type is upgraded to the larger one so the operation can be performed without loss of information.
Example: int + float → both become float.
2. Operator semantics – each operator has a precise definition that the type’s implementation follows.
3. Precedence and associativity – Python evaluates operators in a fixed order (e.g., ** before *, /, //, %, which are before + and -). Parentheses () can override this order.

The arithmetic operators in detail

- + (addition) – adds two numbers. For int and float it’s straightforward; for complex it adds both real and imaginary parts.
- - (subtraction) – subtracts right operand from left.
- * (multiplication) – multiplies. With complex, it follows the algebraic rule (a+bj)*(c+dj).
- / (true division) – always returns a float, even if both operands are int. Python uses real‑number division, not integer truncation.
- // (floor division) – divides then floors the result to the nearest lower integer (or lower whole number for negatives). The result type matches the left operand’s type when possible.
- % (modulo) – returns the remainder after floor division. It satisfies the identity a == (a // b) * b + (a % b).
- ** (exponentiation) – raises left operand to the power of right operand. Supports fractional exponents, which produce a float.

Why does // floor the result?

Python defines floor division as:

a // b  ==  math.floor(a / b)


math.floor always moves toward negative infinity. This behaviour is consistent with the modulo operator’s identity above, ensuring that (a // b) * b + (a % b) exactly reconstructs the original a.

A complete, runnable example

Below is a short script that touches every numeric type and each arithmetic operator. Run it line‑by‑line to see the output and understand what’s happening.

# Numbers and arithmetic operators demo

# 1️⃣ Define values of different numeric types
a = 7 # int: whole number
b = 3.5 # float: decimal number
c = 2 + 3j # complex: real + imag*j

# 2️⃣ Basic operations
add = a + b # int + float → float
sub = a - b # subtraction → float
mul = a * b # multiplication → float
true_div = a / b # true division → float
floor_div = a // b # floor division → int (floors 2.0)
mod = a % b # remainder after floor division → float
exp = a ** 2 # exponentiation → int
complex_mul = c * (1 - 1j) # complex multiplication

# 3️⃣ Show results
print("a (int) =", a)
print("b (float) =", b)
print("c (complex) =", c)
print("a + b =", add) # 10.5
print("a - b =", sub) # 3.5
print("a * b =", mul) # 24.5
print("a / b =", true_div) # 2.0
print("a // b =", floor_div) # 2
print("a % b =", mod) # 0.5
print("a ** 2 =", exp) # 49
print("c * (1-1j) =", complex_mul) # (5+1j)


Line‑by‑line walk‑through

(continued below 👇)
- a = 7 creates an int object holding the value 7.
- b = 3.5 creates a float object.
- c = 2 + 3j creates a complex object where 2 is the real part and 3j is the imaginary part.
- add = a + b triggers type promotion: a is promoted to float, then addition yields 10.5.
- true_div = a / b always returns a float; even though 7 / 3.5 is mathematically 2, the result is 2.0.
- floor_div = a // b computes 7 / 3.5 = 2.0 then floors it to 2. Because the left operand is an int, the result stays an int.
- mod = a % b satisfies 7 == (7 // 3.5) * 3.5 + (7 % 3.5), so 7 % 3.5 equals 0.5.
- exp = a ** 2 squares 7, returning an int (49).
- complex_mul = c * (1 - 1j) uses the algebraic rule (a+bj)*(c+dj) = (ac‑bd) + (ad+bc)j. The result is (5+1j).

Running the script prints each intermediate value, confirming how Python’s numeric tower and operator definitions work together.

Key take‑aways

- Type promotion guarantees operations don’t lose precision.
- / vs //: true division always yields a float; floor division discards the fractional part toward negative infinity.
- Operator precedence follows the standard mathematical hierarchy; use parentheses to make intent explicit.
- Complex numbers behave algebraically, and the same operators apply.

Understanding these fundamentals makes higher‑level numeric work—statistics, scientific computing, graphics—much more predictable.
In Python, what is the result of the expression `7 // 3`?
Anonymous Quiz
33%
2
0%
2.3333333333333335
0%
3
67%
1
📘 Day 7: input() and type conversion

Understanding input() and Type Conversion in Python

When you write a program that interacts with a user, the most common way to get data is the built‑in input() function. It looks simple:

name = input("Enter your name: ")


But what input() actually returns, and why we often need to convert that value, is a fundamental concept that underpins every interactive script.

1️⃣ What input() really does

1. Displays a prompt – the string you pass ("Enter your name: ").
2. Waits for the user to type – everything the user types until they press Enter is captured.
3. Returns a str object – even if the user typed digits, the result is still a string.

Why a string?
The underlying operating system sends the typed characters as a sequence of bytes. Python decodes those bytes using the default text encoding (usually UTF‑8) and builds a string object. This design keeps input() simple and language‑agnostic: the function never guesses the intended data type.

2️⃣ Why you need type conversion

Most programs need numbers, dates, booleans, or other non‑text data. If you try to use the raw string as a number, Python will raise a TypeError or give you unexpected results:

age = input("Enter your age: ")
print(age + 10) # ❌ TypeError: can only concatenate str (not "int") to str


Here age is a string like "27". Adding 10 to a string would mean concatenation, not arithmetic. To perform math we must turn the string into an integer (or a float if decimals are allowed).

3️⃣ The basic conversion functions

Function — Purpose — Typical error

int(s) — Convert string s to an integer — ValueError if s isn’t a valid integer ("12a" )
float(s) — Convert string s to a floating‑point number — ValueError if s isn’t a valid float ("abc" )
bool(s) — Convert string s to a boolean (False only if s is empty or "0" ) — Rarely raises, but truthiness may be confusing

Why these work: Each function parses the characters of the string according to the rules of the target numeric format. If the pattern doesn’t match, Python raises a ValueError to signal that conversion is impossible.

4️⃣ Safe conversion with error handling

A robust script never assumes the user will type the correct format. Use try / except to catch conversion failures and react gracefully:

while True:
raw = input("Enter a positive integer: ")
try:
number = int(raw)
if number > 0:
break # valid input, exit loop
else:
print("⚠️ Must be greater than zero.")
except ValueError:
print("⚠️ That's not an integer. Try again.")


What happens step‑by‑step

1. input() returns whatever the user typed (raw).
2. int(raw) attempts to parse it.
3. If parsing succeeds, we get an int stored in number.
4. If parsing fails, Python jumps to the except block, where we inform the user and repeat.

5️⃣ Full example – a simple calculator

Below is a complete, runnable script that demonstrates prompting, type conversion, error handling, and using the converted values:

(continued below 👇)
# Simple calculator: adds two numbers entered by the user
def get_number(prompt: str) -> float:
"""Repeatedly ask for a number until the user provides a valid float."""
while True:
raw = input(prompt)
try:
# Convert the raw string to a floating‑point number
return float(raw)
except ValueError:
# Inform the user why the conversion failed
print("❌ Invalid number, please type digits like 3.14 or 42.")

def main():
print("🧮 Welcome to the Mini Calculator!")
# Get the first operand
a = get_number("Enter the first number: ")
# Get the second operand
b = get_number("Enter the second number: ")

# Perform addition – now we can use '+' as arithmetic because a & b are floats
result = a + b

# Show the result, formatted to two decimal places
print(f"✅ The sum of {a:.2f} and {b:.2f} is {result:.2f}")

if __name__ == "__main__":
main()


Walk‑through

- def get_number(prompt: str) -> float: – a helper that encapsulates the input‑convert‑retry pattern.
- raw = input(prompt) – captures the user’s raw text.
- float(raw) – attempts to turn that text into a numeric value (float).
- except ValueError: – catches the situation where the text isn’t a valid number and prompts again.
- a = get_number(...) & b = get_number(...) – we now have two float objects, ready for arithmetic.
- result = a + b – normal numeric addition works because both operands are numbers, not strings.
- print(f"...") – formatted output shows the numbers with two decimal places, demonstrating that they are indeed numeric.

6️⃣ Common pitfalls & best practices

- Never trust raw input – always validate and convert.
- Prefer float for generic numeric entry – it accepts both integer‑style ("5") and decimal ("5.0").
- Use strip() if you want to ignore surrounding whitespace: raw = input(...).strip().
- Remember that bool("False") is True – the string is non‑empty, so it evaluates to True. Use explicit checks (raw.lower() in ("true", "yes", "1")).
- Separate concerns – keep prompting/validation in its own function (as shown) to keep the main logic clean.

7️⃣ Quick bolded checklist

- input() always returns a str.
- Convert with int(), float(), or custom parsing.
- Wrap conversions in try/except to handle bad data.
- Validate ranges (e.g., positive numbers) after conversion.
- Never mix raw strings with numeric operators.

Mastering input() and type conversion is the first step toward building reliable, user‑friendly command‑line applications. Once you internalize the why behind each step, you’ll write safer code with confidence.
What does the built‑in `input()` function return in Python?
Anonymous Quiz
0%
An integer
100%
A string
0%
A list
0%
A boolean
📘 Day 8: Lists: creating, indexing, slicing, and common methods

Lists are one of Python’s most versatile built‑in data structures. Think of a list as an ordered collection of items that can be of any type—numbers, strings, even other lists. Because a list is mutable, you can change its contents after creation, which is why understanding how they work is essential for writing flexible code.

Creating a list
- Use square brackets [] and separate items with commas.
- An empty list is just [].
- You can also build a list from an iterable with the list() constructor.

# Different ways to create lists
numbers = [1, 2, 3, 4] # literal syntax
empty = [] # an empty list
mixed = ['apple', 3, 4.5, True] # any mix of types
from_range = list(range(5)) # converts a range into a list


Why does list(range(5)) work?
range(5) produces a range object that lazily generates numbers 0‑4. The list() constructor iterates over that object, collecting each number into a new list. This illustrates that any iterable can be turned into a list.

Indexing – accessing a single element
- Positive indices start at 0 (first element).
- Negative indices start at -1 (last element).
- An out‑of‑range index raises IndexError.

fruits = ['apple', 'banana', 'cherry']
first = fruits[0] # 'apple'
last = fruits[-1] # 'cherry'


Why does fruits[-1] give the last item?
Python translates a negative index -n to len(fruits) - n. So -1 becomes 3 - 1 = 2, which points to the element at position 2.

Slicing – extracting a sub‑list
Syntax: my_list[start:stop:step]
- start is inclusive, stop is exclusive.
- Omitting start defaults to the beginning; omitting stop defaults to the end.
- step controls the stride; a negative step reverses direction.

nums = list(range(10))   # [0,1,2,3,4,5,6,7,8,9]

first_three = nums[0:3] # [0, 1, 2]
middle = nums[3:7] # [3, 4, 5, 6]
every_second = nums[::2] # [0, 2, 4, 6, 8]
reverse = nums[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]


Why does slicing return a new list?
When you slice, Python creates a shallow copy containing references to the original elements. This protects the original list from accidental modification via the slice.

Common list methods – mutating the list in place

Bolded list of the most used methods
- append(item) – adds item to the end.
- extend(iterable) – concatenates another iterable’s items.
- insert(index, item) – inserts item before index.
- remove(value) – deletes the first occurrence of value.
- pop([index]) – removes and returns element at index (default last).
- clear() – empties the list completely.
- sort(key=None, reverse=False) – sorts in place.
- reverse() – reverses order in place.
- count(value) – counts occurrences of value.
- index(value, start=0, stop=len(list)) – finds first index of value.

# Demonstrating several list methods in one script
shopping = ['bread', 'milk']
shopping.append('eggs') # ['bread', 'milk', 'eggs']
shopping.extend(['cheese', 'butter']) # ['bread', 'milk', 'eggs', 'cheese', 'butter']
shopping.insert(1, 'apples') # ['bread', 'apples', 'milk', 'eggs', 'cheese', 'butter']
shopping.remove('milk') # removes first 'milk'
last_item = shopping.pop() # removes and returns 'butter'
shopping.sort() # alphabetical order
shopping.reverse() # now descending order
count_apples = shopping.count('apples')
pos_apples = shopping.index('apples')
print('Final list:', shopping)
print('Removed item:', last_item)
print('Apples count:', count_apples, 'at position', pos_apples)


(continued below 👇)
❤1
Walk‑through of the example
1. shopping = ['bread', 'milk'] creates a list with two strings.
2. append('eggs') adds 'eggs' to the tail, expanding the list in place.
3. extend([...]) takes each element from the new iterable and appends them sequentially—more efficient than multiple append calls.
4. insert(1, 'apples') shifts existing items rightward, placing 'apples' at index 1.
5. remove('milk') searches left‑to‑right for the first 'milk' and deletes it.
6. pop() without an argument defaults to the last element; it also returns that element so we can store it in last_item.
7. sort() rearranges the list alphabetically because all elements are strings.
8. reverse() flips the order, demonstrating that reverse works on the already‑sorted list.
9. count('apples') and index('apples') illustrate read‑only queries that do not modify the list.
10. The final print statements show the resulting list and the values we captured.

Understanding why each method behaves as it does helps you predict side‑effects—crucial when working with mutable objects in larger programs.

Key takeaways
- Lists are ordered, mutable, and can hold heterogeneous items.
- Indexing uses zero‑based positions; negative indices count from the end.
- Slicing produces a shallow copy, letting you safely work on sub‑segments.
- The built‑in methods modify the list in place (except those that return a value, like pop).

Practice by experimenting: change the step in a slice, combine extend with other iterables, and observe how sort behaves with numbers versus strings. Mastery of lists unlocks the majority of everyday Python data handling.
Which expression returns a new list containing the last three elements of `my_list` in reverse order?
Anonymous Quiz
67%
my_list[-3:]
0%
my_list[-3:][::-1]
33%
my_list[::-3]
0%
my_list[-1:-4]
📘 Day 9: Tuples and when to use them instead of lists

Tuples are one of Python’s built‑in sequence types. Like lists, they store an ordered collection of items, but they differ in a crucial way: a tuple’s contents cannot be changed after creation. This immutability gives tuples distinct advantages and influences when you should pick them over lists.

How a tuple is created

# A tuple containing three different types
coordinates = (12.5, -3, "point A")


* ( and ) delimit the tuple.
* Inside we place any objects, separated by commas.
* Even a single element needs a trailing comma: single = (42,).

Why immutability matters

* Safety – Once a tuple is built, its items stay the same. Accidentally modifying data in a large codebase becomes impossible, which prevents bugs.
* Hashability – Immutable objects can be hashed, so a tuple can be used as a dictionary key or placed inside a set; lists cannot.
* Performance – Because Python knows a tuple won’t change, it can store it more compactly and access items slightly faster than a list.

When to prefer a tuple over a list

Use a tuple when:

- The collection represents a fixed collection of items (e.g., coordinates, RGB values, database rows).
- You need to protect data from accidental modification.
- You want to use the collection as a key in a dict or store it in a set.
- You care about the tiny speed/ memory edge for read‑only sequences.

Use a list when:

- The size or contents will change (add, remove, reorder).
- You need list‑specific methods like append, extend, pop, remove.
- The collection is a dynamic container such as a queue, stack, or buffer.

Bolded comparison list

- Mutability – List: mutable | Tuple: immutable
- Typical use – List: dynamic collections | Tuple: fixed collections
- Hashable – List: no | Tuple: yes (if all elements are hashable)
- Methods – List: many (append, pop…) | Tuple: few (count, index)

Real‑world example: Using a tuple as a dictionary key

# Suppose we store the population of several cities.
# The (country_code, city_name) pair uniquely identifies a city.
city_population = {
("US", "New York"): 8_398_748,
("JP", "Tokyo"): 13_960_236,
("FR", "Paris"): 2_148_271,
}

# Retrieve the population of Tokyo
print(city_population[("JP", "Tokyo")])


Walk‑through

1. Define the dictionary – Keys are tuples ("US", "New York") etc. Because tuples are immutable, Python can hash them and use them as keys.
2. Assign values – The values are the populations (integers).
3. Access a value – city_population[("JP", "Tokyo")] looks up the exact tuple key and returns the associated number.
4. Print – The print call outputs 13960236.

If we had tried to use a list as a key (["JP", "Tokyo"]), Python would raise a TypeError: unhashable type: 'list'.

More subtle benefits

- Function return values – Functions that need to return multiple related values often use a tuple, e.g., return min_val, max_val. The caller receives a fixed‑size, immutable package.
- Unpacking – Tuples support clean unpacking:


x, y, label = coordinates   # x=12.5, y=-3, label='point A'


Because the size is fixed, a mismatch in the number of variables raises a clear error, catching bugs early.

Converting between lists and tuples

my_list = [1, 2, 3]
my_tuple = tuple(my_list) # list → tuple
back_to_list = list(my_tuple) # tuple → list


The conversion functions tuple() and list() simply copy the elements into a new container of the desired type. No data is lost; only mutability changes.

TL;DR

(continued below 👇)
- Tuples = immutable, hashable, slightly faster, ideal for fixed collections.
- Lists = mutable, feature‑rich, meant for data that changes.
Choose the type that matches the intent of your data, not just the current shape.
Which of the following is a valid reason to choose a tuple over a list in Python?
Anonymous Quiz
50%
It allows item modification
0%
It can be used as a dict key
50%
It supports .append() method
0%
It automatically sorts its items
📘 Day 10: Dictionaries: keys, values, and common methods

Dictionaries are one of Python’s core data structures. Think of a dictionary as a real‑world map: you look up a value (the value) by providing a unique identifier (the key). Under the hood, Python implements a dictionary as a hash table, which gives average‑case O(1) time for look‑ups, inserts, and deletions.

How a key works
- A key must be immutable (e.g., int, float, str, tuple of immutables).
- When you use a key, Python runs its __hash__ method to produce a hash code, then uses that code to decide where to store the value in the internal table.
- Two different objects can have the same hash (a collision). Python resolves collisions by chaining entries together, so lookup still stays fast.

Values can be any Python object: numbers, strings, lists, even other dictionaries.

Creating a dictionary

# A simple price‑list dictionary
prices = {
"apple": 0.60,
"banana": 0.40,
"cherry": 2.00
}


- The curly braces {} denote a dictionary literal.
- Each entry is written as key: value, separated by commas.
- Keys are strings here, but could be integers or tuples.

Accessing, adding, and updating

# Access a value – raises KeyError if the key is missing
apple_price = prices["apple"] # 0.60

# Safely get a value with a default – no exception
orange_price = prices.get("orange", 0.0) # 0.0

# Add a new entry or update an existing one
prices["date"] = 1.50 # adds 'date'
prices["banana"] = 0.45 # updates 'banana'


- prices["apple"] looks up the hash of "apple" and fetches the associated value.
- dict.get(key, default) first tries the normal lookup; if the key is absent it returns default instead of raising an error.

Removing items

# Remove a specific key, returning its value
removed = prices.pop("cherry") # removed == 2.00

# Remove an arbitrary (last) key‑value pair – useful for LIFO stacks
key, value = prices.popitem() # e.g., ('date', 1.50)

# Clear the whole dictionary
prices.clear() # now {}


- pop uses the key’s hash to locate the entry, removes it, and returns the value.
- popitem is fast because it doesn’t need to search; it just removes the last slot used.

Viewing the contents

keys_view   = prices.keys()     # dict_keys(['apple', 'banana'])
values_view = prices.values() # dict_values([0.60, 0.45])
items_view = prices.items() # dict_items([('apple', 0.60), ('banana', 0.45)])


- These methods return view objects that reflect the dictionary’s current state.
- They are iterable, so you can loop over them directly.

Common utility methods

- setdefault(key, default): returns the value for key if present; otherwise inserts key with default and returns default.
- update(other_dict): merges another dictionary (or iterable of key‑value pairs) into the current one, overwriting existing keys.
- copy(): shallow copy of the dictionary (new container, same values).

Why these methods exist
All of them rely on the same hashing mechanism. Because the hash of a key never changes during its lifetime, the dictionary can safely keep the entry at a fixed slot. Methods like update and setdefault are shortcuts that avoid writing explicit if … else checks, making code both clearer and more efficient.

A complete runnable example

(continued below 👇)