BASIC Python (Beginner Level) 📂 Basic Python Programming · 8 of 15 47 min read

Python Lists — Memory Model, Indexing, Slicing

Master Python lists from the ground up. This tutorial explains how lists are stored in memory as arrays of references (with animated diagrams), positive and negative indexing, complete slicing syntax with start/stop/step, all 11 built-in list methods split into mutators/queries/builders, five different for-loop patterns including enumerate, zip, break/continue/else, list comprehensions, nested lists for 2D grids, and a full inventory-manager practice program.

Section 01

The Story That Explains Lists

A Row of Numbered Lockers
Picture a row of school lockers, each with a number painted on the front: 0, 1, 2, 3, 4. Inside each locker is a photo of the person it belongs to. You can walk up to locker 3 and see whose photo is inside. You can swap the photo for someone else's. You can add a new locker at the end. You can even take a range of lockers — say lockers 1 through 3 — and photocopy them into a new row.

A Python list is that row of lockers. Each slot has a numbered position (an index), and each slot holds one thing. Unlike lockers, Python's list can hold anything in each slot — a number, a string, another list, a function, even different types mixed together. And the row can grow or shrink whenever you like.

But here's the twist that makes lists so powerful: the lockers don't actually store the photos themselves — they store tickets telling you where the photos are stored. That indirection is why Python lists are so flexible, and it's the key to understanding how they work in memory.

A list (list) is Python's built-in ordered, mutable sequence type. Ordered means each element has a position; mutable means you can change, add, or remove elements after creation. Lists are the most-used collection type in Python — you'll reach for one dozens of times a day.

📑
Ordered
Position matters
Elements keep the order you put them in. Item at index 0 stays at index 0 until you explicitly move it. This lets you use indexing and slicing.
🔧
Mutable
Change in place
Unlike strings and tuples, lists can be modified without creating a new object. You can append, insert, remove, or reassign elements — all in the same list.
🎗
Heterogeneous
Any type allowed
One list can hold integers, strings, floats, booleans, other lists, functions — anything. Python doesn't require all elements to be the same type.

Section 02

Creating Lists — Four Ways

# ─── 1. LITERAL SYNTAX (most common) ────────────────────
empty      = []
numbers    = [1, 2, 3, 4, 5]
mixed      = [42, "hello", 3.14, True, None]
nested     = [[1, 2], [3, 4], [5, 6]]

# ─── 2. list() CONSTRUCTOR ──────────────────────────────
from_str   = list("hello")            # ['h', 'e', 'l', 'l', 'o']
from_range = list(range(5))            # [0, 1, 2, 3, 4]
from_tuple = list((10, 20, 30))         # [10, 20, 30]

# ─── 3. REPETITION (quick uniform lists) ────────────────
zeros      = [0] * 5                    # [0, 0, 0, 0, 0]
grid_row   = ["."] * 10                  # ['.', '.', ... 10 dots]

# ─── 4. LIST COMPREHENSION (powerful shortcut) ──────────
squares    = [x**2 for x in range(1, 6)]   # [1, 4, 9, 16, 25]
evens      = [x for x in range(20) if x % 2 == 0]

print(mixed)         # [42, 'hello', 3.14, True, None]
print(squares)       # [1, 4, 9, 16, 25]
print(evens)         # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
⚠️
The Repetition Trap With Nested Lists

[[0]*3]*3 looks like a 3×3 grid — but it's a trap. All three inner lists are the same object, so modifying one modifies all three. Use [[0]*3 for _ in range(3)] instead — a comprehension makes independent inner lists.


Section 03

How Lists Are Stored in Memory

This is the concept that makes lists click. When you write nums = [10, 20, 30], Python does NOT store the numbers 10, 20, 30 directly inside the list. Instead, the list holds a compact array of references (pointers) to those numbers, which live independently elsewhere in memory. This design is what lets one list hold different types at once.

The Visual Model

nums variable LIST OBJECT (contiguous array of references) index 0 ref → index 1 ref → index 2 ref → id: 140100  ·  len: 3  ·  capacity: 4 HEAP — actual objects live here, in separate memory locations 10 int · id: 200100 20 int · id: 200200 30 int · id: 200300
💡
Three Layers to Remember

Layer 1 — the variable nums is a sticky note pointing at the list object.
Layer 2 — the list object is a compact array of slots, each holding a reference (pointer) to an actual value.
Layer 3 — the values (10, 20, 30) live independently in the heap. They are shared with other lists and variables that reference the same values.

Why This Matters — A Mixed-Type List

Because the list only stores references (all of which are the same size — one machine word), the values themselves can be any type and any size: a tiny int, a giant string, another list. This is impossible in C or Java arrays.

mixed = [42, "hi", 3.14, True] LIST OBJECT — 4 uniform-size reference slots ref → [0] ref → [1] ref → [2] ref → [3] HEAP — objects of different types & sizes 42 int (28 B) "hi" str (51 B) 3.14 float (24 B) True bool (28 B) Slots are uniform · Objects are heterogeneous · Everything fits.

Consequences of the Reference Model

📈
O(1) Indexing
Because slots are a fixed size and stored contiguously, Python can jump straight to list[i] with one calculation. It doesn't matter if the list has 10 elements or 10 million — accessing any index takes the same time.
Constant time access
🔁
Shared Objects
If two variables hold the same object (e.g., the number 42), they may share the same heap object. Adding 42 to a list doesn't duplicate it — the list just stores a reference to the existing object.
Cheap to store duplicates
🛠️
Growable — But With Bursts
Lists are backed by a dynamic array with over-allocation. When space runs out, Python allocates a bigger array and copies everything over. append() is O(1) on average, occasionally O(n).
Amortised O(1) append
⚠️
Shared List = Shared Trouble
Because a variable holds a reference, doing b = a makes both names point at the same list. Modifying through b is visible through a. Use a.copy() or a[:] for a real copy.
Aliasing gotcha
# ─── PROOF: A LIST STORES REFERENCES ────────────────────
value = 1000            # object outside the small-int cache
data = [value, value, value]

# All three slots reference the SAME object
print(id(data[0]) == id(data[1]))    # True
print(id(data[0]) == id(value))      # True

# ─── PROOF: ALIASING (shared reference) ─────────────────
a = [1, 2, 3]
b = a                       # b is NOT a copy — same list!
b.append(4)                # modifies THE list

print(a)                    # [1, 2, 3, 4] — a sees it too!
print(a is b)               # True — same object

# ─── PROPER COPY ────────────────────────────────────────
c = a.copy()               # new list, same references inside
print(a is c)               # False — independent lists

Section 04

Indexing — Reaching Individual Elements

Since lists are ordered sequences, every element has a numbered position. Python uses zero-based indexing and supports negative indices to count from the end.

colors = ["red", "green", "blue", "yellow", "purple"] "red" "green" "blue" "yellow" "purple" 0 1 2 3 4 positive → -5 -4 -3 -2 -1 ← negative len(colors) = 5  ·  valid indices: -5 to 4  ·  anything else → IndexError
# ─── POSITIVE INDEXING ──────────────────────────────────
colors = ["red", "green", "blue", "yellow", "purple"]

print(colors[0])           # red    — first element
print(colors[2])           # blue   — third element
print(colors[4])           # purple — last element

# ─── NEGATIVE INDEXING ──────────────────────────────────
print(colors[-1])          # purple — last (short & sweet)
print(colors[-2])          # yellow — second to last
print(colors[-5])          # red    — also the first

# ─── MUTATION VIA INDEX (lists are mutable!) ────────────
colors[1] = "forest green"
print(colors)             # ['red', 'forest green', 'blue', 'yellow', 'purple']

# ─── OUT-OF-RANGE CRASHES ───────────────────────────────
# colors[10]   → IndexError: list index out of range
# colors[-10]  → IndexError: list index out of range

# ─── LEN() ──────────────────────────────────────────────
print(len(colors))         # 5
print(colors[len(colors) - 1])  # purple — but colors[-1] is cleaner!

Section 05

Slicing — Grabbing Sub-Lists

Slicing extracts a portion of a list into a new list. Same syntax as strings: lst[start:stop:step]. All three parts are optional. Unlike indexing, slicing never crashes on out-of-range values — it just returns what's available.

Slice Syntax
lst[start : stop : step]
start = inclusive begin (default 0)  ·  stop = exclusive end (default len)  ·  step = jump size (default 1)
Returns a Copy
new list — original untouched
Slicing always builds a fresh list. Handy: lst[:] is the shortest way to make a shallow copy.
# ─── BASIC SLICING ──────────────────────────────────────
nums = [10, 20, 30, 40, 50, 60, 70, 80]
#      0    1    2    3    4    5    6    7

print(nums[2:5])         # [30, 40, 50]        indices 2,3,4 (NOT 5!)
print(nums[:3])          # [10, 20, 30]        first three
print(nums[5:])          # [60, 70, 80]        from index 5 to end
print(nums[:])           # [10, ..., 80]       shallow copy of whole list

# ─── NEGATIVE INDICES IN SLICES ─────────────────────────
print(nums[-3:])         # [60, 70, 80]        last three
print(nums[:-2])         # [10, ..., 60]       drop last two
print(nums[-5:-2])       # [40, 50, 60]        middle chunk from the end

# ─── STEP: EVERY Nth ELEMENT ────────────────────────────
print(nums[::2])         # [10, 30, 50, 70]    every 2nd
print(nums[1::2])        # [20, 40, 60, 80]    every 2nd starting at 1

# ─── REVERSE THE ENTIRE LIST ────────────────────────────
print(nums[::-1])        # [80, 70, 60, 50, 40, 30, 20, 10]

# ─── OUT-OF-RANGE = SAFE (no crash!) ────────────────────
print(nums[100:200])     # []   — empty list, no error
print(nums[5:100])       # [60, 70, 80] — takes what's available

# ─── SLICE ASSIGNMENT (mutation via slice!) ─────────────
data = [1, 2, 3, 4, 5]
data[1:4] = [99, 99]     # replace 3 elements with 2
print(data)             # [1, 99, 99, 5]

Slicing Cheat Sheet

PatternMeaningExample on [10, 20, 30, 40, 50]
lst[a:b]Indices a through b-1lst[1:4][20, 30, 40]
lst[:n]First n elementslst[:3][10, 20, 30]
lst[n:]From index n to endlst[3:][40, 50]
lst[-n:]Last n elementslst[-2:][40, 50]
lst[:-n]Everything except last nlst[:-2][10, 20, 30]
lst[::-1]Reverse the entire listlst[::-1][50, 40, 30, 20, 10]
lst[::2]Every 2nd elementlst[::2][10, 30, 50]
lst[:]Shallow copy of the listlst[:][10, 20, 30, 40, 50]

Section 06

List Methods — The Essential Toolkit

Lists come with 11 built-in methods. They fall into three categories: mutators (change the list in place, return None), queries (return information about the list), and builders (produce a copy).

Mutators — Change the List In Place

# ─── append(x) — add one item to the end ────────────────
tasks = ["code", "test"]
tasks.append("deploy")
print(tasks)                         # ['code', 'test', 'deploy']

# ─── extend(iterable) — add many items ──────────────────
tasks.extend(["review", "ship"])
print(tasks)                         # ['code', 'test', 'deploy', 'review', 'ship']

# COMPARE: append vs extend
a = [1, 2]
a.append([3, 4])                     # adds ONE item (which happens to be a list)
print(a)                             # [1, 2, [3, 4]]

b = [1, 2]
b.extend([3, 4])                     # adds each item individually
print(b)                             # [1, 2, 3, 4]

# ─── insert(i, x) — put x at index i ────────────────────
letters = ["a", "c", "d"]
letters.insert(1, "b")                 # put 'b' at index 1
print(letters)                       # ['a', 'b', 'c', 'd']

# ─── remove(x) — delete first occurrence of x ───────────
nums = [1, 2, 3, 2, 1]
nums.remove(2)                        # removes the FIRST 2 only
print(nums)                          # [1, 3, 2, 1]
# nums.remove(99)  → ValueError: not in list

# ─── pop(i=-1) — remove & return item at index i ────────
stack = [10, 20, 30, 40]
last = stack.pop()                    # default = last element
print(last, stack)                   # 40 [10, 20, 30]

first = stack.pop(0)                  # pop from front (slow-ish for big lists)
print(first, stack)                  # 10 [20, 30]

# ─── sort() — sort in place (returns None!) ─────────────
data = [3, 1, 4, 1, 5, 9, 2, 6]
data.sort()                            # ascending by default
print(data)                          # [1, 1, 2, 3, 4, 5, 6, 9]

data.sort(reverse=True)                # descending
print(data)                          # [9, 6, 5, 4, 3, 2, 1, 1]

words = ["apple", "kiwi", "strawberry"]
words.sort(key=len)                    # sort by length
print(words)                         # ['kiwi', 'apple', 'strawberry']

# ─── reverse() — flip in place ──────────────────────────
seq = [1, 2, 3, 4]
seq.reverse()
print(seq)                           # [4, 3, 2, 1]

# ─── clear() — empty the list ───────────────────────────
seq.clear()
print(seq)                           # []
⚠️
Mutators Return None — Not the List!

A classic trap: nums = nums.sort() silently makes nums equal to None. Mutators change the list in place and return None. Either call them alone (nums.sort()) or use the builder equivalents (sorted(nums), reversed(nums)) which return a new object.

Queries — Return Information

# ─── count(x) — how many times x appears ────────────────
votes = ["yes", "no", "yes", "yes", "no"]
print(votes.count("yes"))              # 3
print(votes.count("maybe"))            # 0 (safe — no error)

# ─── index(x) — position of FIRST occurrence ────────────
print(votes.index("no"))               # 1
# votes.index("maybe")  → ValueError: not in list

# Safe index check with 'in' first
if "no" in votes:
    print(f"Found at {votes.index('no')}")

# ─── LEN, MIN, MAX, SUM (built-ins, not methods) ────────
scores = [88, 75, 92, 60, 83]
print(len(scores))                      # 5
print(min(scores), max(scores))          # 60 92
print(sum(scores))                      # 398
print(sum(scores) / len(scores))          # 79.6

Builders — Return a New List

# ─── copy() — shallow copy of the list ──────────────────
original = [1, 2, 3]
duplicate = original.copy()             # same as original[:]

duplicate.append(4)
print(original)                        # [1, 2, 3]        untouched
print(duplicate)                       # [1, 2, 3, 4]     independent copy

# ─── sorted() & reversed() — the safe builders ──────────
# These are BUILT-IN FUNCTIONS, not methods, and they return NEW objects
data = [3, 1, 2]

print(sorted(data))                    # [1, 2, 3]         (new list)
print(data)                            # [3, 1, 2]         (original unchanged)

print(list(reversed(data)))           # [2, 1, 3]

Complete Methods Reference

CategoryMethodWhat It DoesReturns
Mutators.append(x)Add one item to the endNone
.extend(iter)Add each item from iterableNone
.insert(i, x)Insert x at index iNone
.remove(x)Delete first occurrence of xNone
.pop([i])Remove & return item at index i (default -1)the item
.sort(key, reverse)Sort in placeNone
.reverse()Reverse in placeNone
Cleanup.clear()Remove all elementsNone
del lst[i]Delete element(s) by index or sliceNone
Queries.count(x)Number of occurrencesint
.index(x)Position of first occurrenceint or ValueError
Builder.copy()Shallow copy of the listnew list

Section 07

The for Loop — Iterating Over Lists

The for loop is how you walk through a list, one element at a time. Python's for is fundamentally a for-each loop — it iterates over the elements directly, not over indices like C. This makes it read like English.

Pattern 1 — Iterate Over Values (the default)

fruits = ["apple", "banana", "cherry"]

for fruit in fruits:
    print(f"I love {fruit}")

# OUTPUT:
# I love apple
# I love banana
# I love cherry

Pattern 2 — Iterate With Index Using enumerate()

# enumerate() gives you (index, value) pairs — the Pythonic way
fruits = ["apple", "banana", "cherry"]

for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

# OUTPUT:
# 0: apple
# 1: banana
# 2: cherry

# Start counting from 1 instead of 0
for num, fruit in enumerate(fruits, start=1):
    print(f"Item #{num}: {fruit}")
❌ C-style (unpythonic)
Code
for i in range(len(fruits)):
    print(i, fruits[i])
Works, but ugly. Requires len() and indexing.
✅ Pythonic
Code
for i, fruit in enumerate(fruits):
    print(i, fruit)
Clean, fast, self-documenting.

Pattern 3 — Iterate Over Two Lists With zip()

names  = ["Alice", "Bob", "Carol"]
scores = [92, 85, 78]

for name, score in zip(names, scores):
    print(f"{name}: {score}")

# OUTPUT:
# Alice: 92
# Bob: 85
# Carol: 78

# zip stops at the shortest list
short = ["x", "y"]
long  = [1, 2, 3, 4]
for a, b in zip(short, long):
    print(a, b)                # only 2 iterations

Pattern 4 — Building a New List From a For Loop

numbers = [1, 2, 3, 4, 5]

# Explicit for loop
squares = []
for n in numbers:
    squares.append(n * n)
print(squares)                # [1, 4, 9, 16, 25]

# Same result — one-line comprehension (more Pythonic for simple cases)
squares = [n * n for n in numbers]
print(squares)                # [1, 4, 9, 16, 25]

# With a filter
even_squares = [n * n for n in numbers if n % 2 == 0]
print(even_squares)           # [4, 16]

Pattern 5 — Loop With break, continue, and else

# ─── break — exit the loop early ────────────────────────
numbers = [1, 3, 5, 8, 10, 12]

for n in numbers:
    if n % 2 == 0:
        print(f"First even: {n}")
        break
# First even: 8

# ─── continue — skip current, keep looping ──────────────
for n in numbers:
    if n % 2 != 0:
        continue              # skip odd numbers
    print(n)                   # 8, 10, 12

# ─── for-else — runs ONLY if loop finished without break ─
for n in numbers:
    if n == 99:
        print("Found 99!")
        break
else:
    print("99 not found in the list")   # this runs
💡
Don't Mutate a List While Iterating

Removing or inserting items into a list while you loop over it causes silent skips, IndexErrors, or infinite loops. If you need to filter, build a new list with a comprehension: nums = [n for n in nums if n > 0]. Or iterate over a copy: for n in nums[:]:.


Section 08

Nested Lists — Lists of Lists

Because a list can hold any type, one list can hold other lists. This is how you build 2D grids, matrices, tables, and tree-like structures in Python.

# ─── A 3x3 GRID ─────────────────────────────────────────
grid = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Access with double indexing: grid[row][col]
print(grid[0][0])         # 1  — top-left
print(grid[2][2])         # 9  — bottom-right
print(grid[1][2])         # 6  — middle row, last col

# Modify one cell
grid[1][1] = 0
print(grid)             # [[1, 2, 3], [4, 0, 6], [7, 8, 9]]

# ─── LOOP OVER A 2D GRID ────────────────────────────────
for row in grid:
    for cell in row:
        print(cell, end=" ")
    print()

# OUTPUT:
# 1 2 3
# 4 0 6
# 7 8 9

# ─── WITH ROW/COL INDICES ───────────────────────────────
for r, row in enumerate(grid):
    for c, cell in enumerate(row):
        if cell == 0:
            print(f"Zero at ({r},{c})")

# ─── BUILD A 5x5 MULTIPLICATION TABLE ───────────────────
table = [[i * j for j in range(1, 6)] for i in range(1, 6)]
for row in table:
    print(row)

# OUTPUT:
# [1, 2, 3, 4, 5]
# [2, 4, 6, 8, 10]
# [3, 6, 9, 12, 15]
# [4, 8, 12, 16, 20]
# [5, 10, 15, 20, 25]

Section 09

Complete Practice Program — Inventory Manager

# inventory.py — every list concept in one working program

def main():
    # ─── CREATE — literal syntax ────────────────────────
    inventory = ["apple", "bread", "milk", "eggs", "cheese"]
    prices    = [1.20, 3.50, 2.80, 4.00, 5.50]
    quantity  = [12, 5, 8, 24, 3]

    line = "─" * 50
    print(f"{line}\n  INVENTORY REPORT\n{line}")

    # ─── LOOP WITH zip() — parallel iteration ───────────
    print(f"  {'ITEM':<10}{'QTY':>6}{'PRICE':>10}{'TOTAL':>12}")
    grand_total = 0
    for item, qty, price in zip(inventory, quantity, prices):
        total = qty * price
        grand_total += total
        print(f"  {item:<10}{qty:>6}${price:>9.2f}${total:>11.2f}")
    print(f"{line}\n  Grand total: ${grand_total:.2f}")

    # ─── QUERIES — len, min, max, sum ──────────────────
    print(f"\n  Items in stock:  {len(inventory)}")
    print(f"  Cheapest item:   ${min(prices):.2f}")
    print(f"  Priciest item:   ${max(prices):.2f}")
    print(f"  Total units:     {sum(quantity)}")

    # ─── INDEXING & SLICING ────────────────────────────
    print(f"\n  First item:  {inventory[0]}")
    print(f"  Last item:   {inventory[-1]}")
    print(f"  Top 3:       {inventory[:3]}")
    print(f"  Reversed:    {inventory[::-1]}")

    # ─── MUTATORS — append, insert, remove, sort ────────
    inventory.append("yogurt")
    inventory.insert(0, "almonds")
    inventory.remove("eggs")
    inventory.sort()
    print(f"\n  Updated (sorted): {inventory}")

    # ─── LIST COMPREHENSION — build from existing ──────
    on_sale = [item for item, price in zip(inventory, prices) if price < 3]
    print(f"  Items under $3:  {on_sale}")

    # ─── FOR-ELSE — search pattern ─────────────────────
    target = "chocolate"
    for item in inventory:
        if item == target:
            print(f"  Found {target}!")
            break
    else:
        print(f"  {target} is out of stock")

if __name__ == "__main__":
    main()
OUTPUT
────────────────────────────────────────────────── INVENTORY REPORT ────────────────────────────────────────────────── ITEM QTY PRICE TOTAL apple 12$ 1.20$ 14.40 bread 5$ 3.50$ 17.50 milk 8$ 2.80$ 22.40 eggs 24$ 4.00$ 96.00 cheese 3$ 5.50$ 16.50 ────────────────────────────────────────────────── Grand total: $166.80 Items in stock: 5 Cheapest item: $1.20 Priciest item: $5.50 Total units: 52 First item: apple Last item: cheese Top 3: ['apple', 'bread', 'milk'] Reversed: ['cheese', 'eggs', 'milk', 'bread', 'apple'] Updated (sorted): ['almonds', 'apple', 'bread', 'cheese', 'milk', 'yogurt'] Items under $3: ['almonds', 'bread'] chocolate is out of stock

Section 10

Golden Rules

🐍 Python Lists — Non-Negotiable Rules
1
A list stores references, not values. Each slot is a pointer to an object elsewhere in memory. This is why one list can hold any mix of types and why aliasing surprises exist.
2
b = a is NOT a copy. Both names now point at the same list. Modifying through either is visible through the other. Use a.copy(), a[:], or list(a) when you need an independent copy.
3
Mutators return None, not the list. nums = nums.sort() silently sets nums to None. Either call the mutator alone or use the builder equivalent (sorted(), reversed()).
4
Slice stop is exclusive. lst[2:5] gives you indices 2, 3, 4 — not 5. Same convention as strings and range(). Slicing is safe: out-of-range values return an empty or truncated list, they never crash.
5
Prefer enumerate() over range(len(lst)). The C-style pattern is a code smell in Python. for i, item in enumerate(lst): is cleaner, faster, and self-documenting.
6
Never mutate a list while iterating over it. Removing items shifts indices and causes silent bugs. Build a new filtered list with a comprehension, or iterate over a shallow copy with for x in lst[:]:.
7
Don't use [[0]*3]*3 for a 2D grid. All inner lists become the same object. Use [[0]*3 for _ in range(3)] — a comprehension makes each row an independent list.
8
Choose the right operation for the job. append() for one item, extend() for many. pop() when you need the removed value, remove() or del when you don't. in for membership checks, index() only when you need the position.