§2  Lists · Tuples · Strings
Python Programming Series  ·  Article 2

Lists, Tuples, and Strings: A Complete Beginner's Guide

Almost everything you do in Python involves putting values in some kind of order: a list of orders, a fixed pair of coordinates, a sentence of characters. Python gives you three core building blocks for this: lists, tuples, and strings. They look similar on the surface (you can index them, slice them, loop over them) but they differ in one important way: whether you're allowed to change them after they're created.

Lists: mutable & flexible
Tuples: sealed & fixed
Strings: immutable text
Real code from Netflix, Amazon, Google, Stripe
Lists
Tuples
Strings

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

python
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.

python
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:

python
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:

python
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:

python
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:

python
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

python
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.

python
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):

python
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:

python
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:

python
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

python
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.

python
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.

python
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.

python
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.

python
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.

python
# 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

python
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

python
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:

python
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:

python
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:

python
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:

python
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:

python
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.

python
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.

python
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

python
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:

python
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
python
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:

python
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:

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.