This guide covers every operation from the source notebook in detail, with real examples from Netflix, Amazon, Google, and Stripe, plus visual diagrams for the trickier parts (slicing, mutability, and the copy behaviors that catch almost everyone off guard at least once).
The Big Picture: Ordered, But Different Rules
All three are sequences: ordered collections you can index by position, starting at 0.
Index: 0 1 2 3 4
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
List: │ 10 │ │ 20 │ │ 30 │ │ 40 │ │ 50 │ ← can edit any cell
└────┘ └────┘ └────┘ └────┘ └────┘
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
Tuple: │ 10 │ │ 20 │ │ 30 │ │ 40 │ │ 50 │ ← sealed, no edits
└────┘ └────┘ └────┘ └────┘ └────┘
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
String: │ 'P'│ │ 'y'│ │ 't'│ │ 'h'│ │ 'o'│ ← sealed, no edits
└────┘ └────┘ └────┘ └────┘ └────┘
| List | Tuple | String | |
|---|---|---|---|
| Written with | [ ] |
( ) |
' ' or " " |
| Can hold mixed types? | Yes | Yes | Only characters |
| Can you change it after creating it? | Yes (mutable) | No (immutable) | No (immutable) |
| Typical use | A collection that will grow, shrink, or get edited | A fixed record that should never change | Text |
That single "can you change it?" column is the most important thing to understand in this whole guide. Everything else follows from it.
Part 1: Lists: The Flexible Container
A list is a collection that can hold mixed data types and can be freely changed after creation: items added, removed, replaced, reordered. Think of it as a shopping list on paper: you can cross items out, scribble in new ones, and reorder the whole thing.
Creating a List
list1 = list() # empty list
list2 = [10, 20, 30, 40, 50, 60, 70] # all integers
list3 = [10, 20, 30, 40, 50, 60.2, 70.5] # mixed int/float
list4 = ['Python', 'Maths', 'Science'] # strings
list5 = [10, 20, 30, 40.5, 78.88, 'Python', [10, 11, 12]] # a list can hold ANYTHING, even another list
That last example, list5, is worth pausing on: a list can hold numbers, strings, and even other lists, all at once. This is what "heterogeneous" means: no single type is enforced.
Indexing and Slicing
Indexing pulls out a single item by its position. Slicing pulls out a range of items and gives you back a new list.
list2[3] * 2 # index 3 is 40, so this returns 80
list2[2:5] # items at index 2, 3, 4 (stop is EXCLUDED)
list2[1:7] # items at index 1 through 6
list2[3:] # everything from index 3 to the end
list2[:4] # everything up to (not including) index 4
list5[0:5:2] # start:stop:step -> indices 0, 2, 4
list5[0:2] + list5[4:6] # slice, then concatenate two slices together
The part that trips up almost everyone is that the stop index is never included. list2[2:5] gives you indices 2, 3, and 4, not 5.
list2 = [10, 20, 30, 40, 50, 60, 70]
index: 0 1 2 3 4 5 6
list2[2:5] ─────────┐
┌────┐┌────┐┌────┐
│ 30 ││ 40 ││ 50 │ ← stop (index 5) is excluded
└────┘└────┘└────┘
list2[0:5:2] (step of 2, skip every other one)
┌────┐ ┌────┐ ┌────┐
│ 10 │ │ 30 │ │ 50 │
└────┘ └────┘ └────┘
idx 0 idx 2 idx 4
Mutability: Lists Can Be Changed in Place
This is the defining feature of a list. You can reach into any position and replace what's there:
list2[3] = 75 # replace a single value at index 3
list2[2:5] = [100, 120, 77] # replace a whole slice with new values
list2[0:2] = [40] # replace TWO items with just ONE: the list shrinks!
That last line is worth noticing: when you assign a list to a slice, the list doesn't need to be the same length as the slice you're replacing. Python just removes the old items and splices in the new ones, however many there are.
One thing that will not work, even though it looks reasonable:
list2[2:5] = 100 # TypeError: can only assign an iterable
A slice always expects something iterable on the right side (a list, even a one-item list), never a bare number. If you want to put a single value into a slice, wrap it: list2[2:5] = [100].
Lists can also nest, and you can reach inside a nested list to change it:
list5[6][1] = 42 # list5[6] is the nested list; [1] is a position inside IT
Adding Values
There are three ways to add to a list, and they do different things:
list2.append(89) # adds ONE item to the end
list2.extend([34, 88, 12, 23, 55.55]) # adds MULTIPLE items to the end
list2.insert(0, 105) # inserts at a SPECIFIC position (index, value)
A common beginner mix-up: append([34, 88]) would add the whole list as one nested item at the end. extend([34, 88]) unpacks it and adds each value individually. If you want to add several separate items, extend is almost always what you actually want.
Removing Values
list5.pop(5) # removes and RETURNS the item at index 5
list5.remove(78.88) # removes the first item that EQUALS 78.88 (search by value, not position)
del list5[1:] # deletes a whole slice
list5.clear() # empties the list completely, leaving []
pop and remove solve two different problems: use pop when you know where the item is, and remove when you know what the item is but not its position.
Copying a List (and a Common Misconception)
This is the part of the notebook worth double-checking carefully, because the terminology here is easy to get backwards.
list4 = list3 # this is NOT a copy: it's a second name for the SAME list
list5 = list3.copy() # this IS a copy: a new list object
Here's the proof, using id() (which shows an object's memory address):
print(id(list3)) # e.g. 139801428415040
print(id(list4)) # SAME number: list3 and list4 are literally the same object
print(id(list5)) # a DIFFERENT number: list5 is a genuinely separate object
list4 = list3 list5 = list3.copy()
list3 ──┐ list3 ──► [10, 20, 30]
├──► [10, 20, 30] (original)
list4 ──┘ ONE object, list5 ──► [10, 20, 30]
two labels (a separate, new object)
So list4 = list3 isn't a "shallow copy" at all; it's not a copy of any kind. It's two names pointing at the exact same list. Change one, and you change "both," because there's only one list to begin with.
.copy() genuinely does create a new list. But here's the catch: it's a shallow copy, not a deep one. If the list contains other mutable objects (like a nested list), the outer list is new, but the inner nested list is still shared:
nested = [1, 2, [99, 100]]
shallow = nested.copy()
shallow[2].append(101)
print(nested) # [1, 2, [99, 100, 101]] <- changed too!
print(shallow) # [1, 2, [99, 100, 101]]
To get a fully independent copy, nested lists and all, you need Python's copy module:
import copy
truly_independent = copy.deepcopy(nested)
| What it does | Nested objects | |
|---|---|---|
list4 = list3 |
Not a copy at all, just two names for one object | Shared (obviously, it's the same object) |
list3.copy() |
Shallow copy, new outer list | Still shared |
copy.deepcopy(list3) |
Deep copy, fully independent | Also copied, fully independent |
Sorting, Reversing, and Other Useful Methods
list3.sort() # sorts the ACTUAL list in place, ascending by default
list3.sort(reverse=True) # sorts in place, descending
sorted_list = sorted(list5) # returns a NEW sorted list, leaves list5 untouched
sorted_list_des = sorted(list5, reverse=True)
list5.reverse() # reverses the list IN PLACE
The distinction between .sort() and sorted() matters: .sort() is a method that changes the list itself and returns nothing. sorted() is a function that leaves the original alone and hands you back a brand-new sorted list. Use .sort() when you're fine changing the original; use sorted() when you need to keep it intact.
list5.count(10) # how many times does 10 appear?
len(list5) # how many items total?
min(list5) # smallest value
max(list5) # largest value
sum(list5) # total of all values (numeric lists only)
list5.index(120) # the position of the first 120
Real-World Examples
Netflix: Chronological Watch History. Netflix needs to track every show you watch to power the "Continue Watching" row. You might watch the same episode five times, so duplicates must be allowed, and the most recent view goes at the end.
watch_history = ["Stranger Things", "The Crown", "Dark", "The Office"]
watch_history.append("Black Mirror")
watch_history.append("The Office") # duplicates allowed
last_watched = watch_history[-1]
print(f"Full History: {watch_history}")
print(f"Last Watched: {last_watched}")
Full History: ['Stranger Things', 'The Crown', 'Dark', 'The Office', 'Black Mirror', 'The Office']
Last Watched: The Office
Amazon: Shopping Cart. A cart is a list of product IDs. Order matters (recently added items show first), and Amazon allows the same product ID to appear more than once.
amazon_cart = ["B07XJ8C8F5", "B08N5WRWJ5", "B07ZPKN6S2"]
amazon_cart.insert(0, "B08N5WRWJ5") # add another unit of an existing item
amazon_cart.remove("B07XJ8C8F5") # remove by value
print(f"Current Cart IDs: {amazon_cart}")
print(f"Total items in cart: {len(amazon_cart)}")
Current Cart IDs: ['B08N5WRWJ5', 'B08N5WRWJ5', 'B07ZPKN6S2']
Total items in cart: 3
YouTube: Playlist Reordering. A playlist is a list you can freely reorder: move a video from position 4 to position 1, or add a new one at the end.
playlist = ["vid_101", "vid_102", "vid_103", "vid_104"]
moving_vid = playlist.pop(3) # remove and grab the 4th video
playlist.insert(0, moving_vid) # put it first
playlist.append("vid_999")
print(f"Custom Sorted Playlist: {playlist}")
Custom Sorted Playlist: ['vid_104', 'vid_101', 'vid_102', 'vid_103', 'vid_999']
Twitter/X: Post Drafts. Drafts are kept in a list because you might save several with the exact same text, and the order you wrote them in matters. A list preserves every entry, duplicates included, and lets you edit or delete any one of them in place.
# A list of saved drafts
drafts = [
"Python is amazing!",
"Working on a new YouTube video...",
"Coming soon!"
]
# User deletes the oldest draft
del drafts[0]
# User edits the current latest draft
drafts[-1] = "Coming soon! Stay tuned for the Python series."
print(f"Your Saved Drafts: {drafts}")
Your Saved Drafts: ['Working on a new YouTube video...', 'Coming soon! Stay tuned for the Python series.']
Part 2: Tuples: The Locked Container
A tuple looks almost identical to a list, mixed data types, indexing, slicing, but with one rule change that affects everything: once created, it cannot be modified. Think of it as a sealed certificate: the moment it's issued, the values on it are final.
Creating a Tuple
tuple1 = tuple() # empty tuple
tuple2 = (10, 20, 30, 40, 50, 60) # all integers
tuple3 = (10, 20, 30, 40.5, 77.5, 'Python', [12, 34, 77], (54, 99, 90)) # mixed, even nested
Indexing and Slicing Work Exactly Like Lists
tuple2[4] # single item at index 4
tuple2[2:5] # a slice; note this returns a new TUPLE, not a list
Immutability in Action
Try to change a tuple, and Python stops you immediately:
tuple2[3] = 43
TypeError: 'tuple' object does not support item assignment
This isn't a bug or a limitation to work around; it's the entire point of a tuple. It's a guarantee, enforced by the language itself, that this data won't be silently changed somewhere in your program.
"Modifying" a Tuple: Convert, Change, Convert Back
Since you can't edit a tuple directly, the standard workaround is to convert it to a list, make your changes, and convert it back:
user_pins = (1324, 7658, 9989, 4453)
temp = list(user_pins) # convert to a list
temp.append(1100) # now it's editable
user_bins = tuple(temp) # convert back to a tuple
You're not actually "editing" the tuple; you're building an entirely new one. The original user_pins still exists, untouched, until you no longer need it.
Tuple Methods (There Aren't Many)
Because tuples can't be changed, most of the list methods that modify data (append, remove, sort, insert) simply don't exist on tuples. What's left is the small set of methods that only read data:
tuple2.index(30) # position of the first 30
tuple2.count(40) # how many times 40 appears
sum(tuple2) # total (numeric tuples)
min(tuple2) # smallest value
max(tuple2) # largest value
Trying to sort a tuple in place will fail, because .sort() doesn't exist for something that can't be reordered in place:
tuple2.sort()
AttributeError: 'tuple' object has no attribute 'sort'
If you need a sorted version, use the sorted() function instead, and wrap the result back into a tuple:
tuple_sorted = tuple(sorted(tuple2))
sorted() never modifies its input; it returns a new list, which you then convert into a new tuple. The original tuple2 is never touched.
Real-World Examples
Google Maps: Fixed Coordinates. The Eiffel Tower has one, unchanging physical location. Storing coordinates as a tuple means the value literally cannot be accidentally reassigned somewhere in a large codebase.
eiffel_tower_coords = (48.8584, 2.2945) # (Latitude, Longitude)
# eiffel_tower_coords[0] = 49.0 # would raise TypeError, protecting the data
print(f"Latitude: {eiffel_tower_coords[0]}")
print(f"Longitude: {eiffel_tower_coords[1]}")
Latitude: 48.8584
Longitude: 2.2945
Stripe: Frozen Transaction Snapshots. Once a payment is processed, the amount, currency, and timestamp must never change again, for auditing. Tuples model this "this happened, and it's final" data well, and their fixed structure makes unpacking clean.
successful_payment = (29.99, "USD", "2024-05-20", "ch_3Oix22L")
amount, currency, date, txn_id = successful_payment # unpacking
print(f"Receipt: {amount} {currency} processed on {date}")
Receipt: 29.99 USD processed on 2024-05-20
Part 3: Strings: Immutable Text
A string is a sequence of characters, and like a tuple, it's immutable: once created, you can't change a character in place. Every "modification" you make actually builds a brand-new string.
Creating Strings
word1 = 'Python' # single quotes
word2 = "Python Programming" # double quotes: functionally identical
word3 = """This is line 1
This is line 2
This is line 3""" # triple quotes: spans multiple lines
Single and double quotes work identically in Python; the choice is usually just about avoiding awkward escaping (use double quotes if your text contains an apostrophe, for instance). Triple quotes are for text that needs to span several lines.
Indexing and Slicing
Exactly the same rules as lists and tuples, since a string is just a sequence of characters:
word2[3] # the character at index 3
word2[0:6] # the first 6 characters
word2 = "Python Programming"
index: 0 1 2 3 4 5 6 7 8 9 ...
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
│P│ │y│ │t│ │h│ │o│ │n│ │ │ │P│ ...
└─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘
word2[0:6] = "Python" (indices 0 through 5)
String Methods
These are the methods you'll reach for constantly. Every one of them returns a new string; none of them change the original, because strings can't be changed.
| Method | What it does |
|---|---|
.find(sub) |
Returns the index of the first match, or -1 if not found |
.index(sub) |
Same as find, but raises a ValueError if not found |
.replace(old, new) |
Replaces every occurrence of old with new |
.upper() |
Converts to UPPERCASE |
.lower() |
Converts to lowercase |
.title() |
Capitalizes The First Letter Of Each Word |
.capitalize() |
Capitalizes only the very first letter |
.strip() |
Removes whitespace from both ends |
.lstrip() / .rstrip() |
Removes whitespace from the left / right end only |
.split(sep) |
Breaks the string into a list of pieces at each sep |
sep.join(list) |
The reverse of split: glues a list of strings together |
word2.find('Pro') # 7: found, starts at index 7
word2.find('Java') # -1: not found, no error
word2.index('Pro') # 7: same result as find, when it succeeds
word2.index('Java') # ValueError: substring not found <- this one raises instead of returning -1
word2.replace('Python', 'Java') # 'Java Programming', a NEW string
word2 # still 'Python Programming': the original never changes
word2.upper() # 'PYTHON PROGRAMMING'
word2.lower() # 'python programming'
word2.title() # 'Python Programming'
word2.capitalize() # 'Python programming'
" Python".lstrip() # 'Python' (left whitespace gone)
"Python ".rstrip() # 'Python' (right whitespace gone)
" Python ".strip() # 'Python' (both sides gone)
word2.split() # ['Python', 'Programming'] (splits on whitespace by default)
"Python-Programming".split('-') # ['Python', 'Programming'] (splits on a custom delimiter)
" ".join(['Python', 'Programming']) # 'Python Programming' (the reverse of split)
The find vs index distinction is worth remembering specifically: find fails quietly (returns -1), while index fails loudly (raises an error). Use find when "not found" is a normal possibility you want to handle; use index when you're confident the substring should be there and want to know immediately if it isn't.
Getting Input and Formatting Strings
input() always returns a string, even if the user types a number, so you need to convert it explicitly when you need a different type:
name = input('Enter your name: ') # always comes back as a string
age = int(input('Enter your age: ')) # wrap in int() to convert
type(age) # <class 'int'>
For building strings out of variables, f-strings are the cleanest approach:
name = input('Enter your name: ')
place = input('Enter where you live: ')
print(f'My name is {name} and I live in {place}.')
The f before the opening quote tells Python to evaluate anything inside { } and insert it directly into the string.
Real-World Examples
Instagram: Hashtag and Mention Parsing. A caption is just one long string. Instagram scans it for # and @ characters to figure out what should become a clickable link.
caption = "Learning #Python with the best community! @guido_van_rossum"
has_python = "#Python" in caption # membership check
mention_start = caption.find("@")
mention = caption[mention_start:] # slice from that position to the end
print(f"Hashtag Present: {has_python}")
print(f"User Mentioned: {mention}")
Hashtag Present: True
User Mentioned: @guido_van_rossum
Google: URL Slug Sanitization. URLs can't contain spaces, so Google converts your search text into something web-safe by lowercasing it and swapping spaces for hyphens.
search_query = "How to learn Python in 2026"
url_slug = search_query.lower().replace(" ", "-")
print(f"https://www.google.com/search?q={url_slug}")
https://www.google.com/search?q=how-to-learn-python-in-2026
Stripe/Banking: Card Number Masking. PCI compliance rules forbid displaying a full card number, so only the last 4 digits are shown, with everything else replaced by asterisks.
card_number = "4532112233445566"
masked_card = "*" * 12 + card_number[-4:] # slice grabs the last 4 characters
print(f"Payment Method: Visa ending in {masked_card}")
Payment Method: Visa ending in ************5566
Amazon: Dynamic Email Invoicing. Amazon doesn't write a million emails by hand; it fills in one template using f-strings.
customer_name = "Abhishek"
order_item = "Sony Headphones"
delivery_date = "March 20"
email_body = f"""
Hi {customer_name},
Your order for the '{order_item}' has been confirmed!
It will be delivered by {delivery_date}.
"""
print(email_body)
Netflix: Case-Insensitive Search. Searching "STRANGER THINGS" or "stranger things" should return the same result, so both sides of the comparison get normalized to the same case before checking.
movie_database = ["Stranger Things", "The Witcher", "Breaking Bad",
"Pirates of the Caribbean: On Stranger Tides"]
user_search = "stranger"
for movie in movie_database:
if user_search.lower() in movie.lower():
print(f"Result found: {movie}")
Result found: Stranger Things
Result found: Pirates of the Caribbean: On Stranger Tides
Choosing the Right One
Does the data need to change after creation?
│
├── YES, items will be added/removed/edited → LIST
│
└── NO, it should stay fixed forever
│
├── Is it text? → STRING
└── Is it a fixed record/collection? → TUPLE
A quick gut check: if you catch yourself writing a comment like "// don't change this" next to a list, that's usually a sign it should have been a tuple in the first place. The language can enforce that promise for you.
Quick Reference Summary
| Task | List | Tuple | String |
|---|---|---|---|
| Create | [1, 2, 3] |
(1, 2, 3) |
'text' |
| Index | x[0] |
x[0] |
x[0] |
| Slice | x[1:3] |
x[1:3] |
x[1:3] |
| Change a value | x[0] = 5 |
Not allowed | Not allowed |
| Add to it | .append() .extend() .insert() |
Not allowed | Not allowed |
| Remove from it | .pop() .remove() del .clear() |
Not allowed | Not allowed |
| Copy (shallow) | .copy() |
.copy() |
n/a (immutable anyway) |
| True independent copy | copy.deepcopy() |
copy.deepcopy() |
n/a |
| Sort in place | .sort() |
Not allowed | Not allowed |
| Sort, get new object | sorted(x) |
tuple(sorted(x)) |
n/a |
| Reverse in place | .reverse() |
Not allowed | Not allowed |
| Count / find / etc | .count() .index() |
.count() .index() |
.find() .index() .count() |
| Search for text | in |
in |
.find() in |
| Combine two | a + b |
a + b |
a + b |
| Case conversion | n/a | n/a | .upper() .lower() .title() .capitalize() |
| Trim whitespace | n/a | n/a | .strip() .lstrip() .rstrip() |
| Split apart / glue together | n/a | n/a | .split() / sep.join() |
The through-line across all three is really just one question: is this data allowed to change? Lists say yes and give you a full toolkit for editing in place. Tuples and strings say no, and every "change" you make to them is secretly building something new. Once that distinction clicks, the rest, indexing, slicing, which methods exist and which don't, all follows logically from it.
One question decides everything here: is this data allowed to change? Lists say yes. Tuples and strings say no. Once that clicks, indexing, slicing, and the whole list of which methods exist (and which don't) stop feeling like things to memorize and start feeling like logical consequences of one simple rule.