Python: Lists, Creation, Indexing, Slicing Complete Guide

Complete Python list tutorial covering creation, indexing, slicing, nesting, and list operations. Tested examples with positive and negative indexing, step slicing, and the memory model behind lists.

“Data dominates. If you’ve chosen the right data structures, the algorithms are almost self-evident.”

Rob Pike, Notes on Programming in C

Think of a Python list like the grocery list stuck to your fridge. The items sit in the order you wrote them, you can add one at the bottom, cross one out, or change “milk” to “oat milk” without rewriting the whole thing. That is exactly what a Python list gives you in code: an ordered collection you can change whenever you like.

The moment you need to keep track of more than one thing (scores, names, filenames, anything) you reach for a list. A Python list holds items in order, lets you add, remove, and change them freely, and accepts any mix of types. It is the workhorse of the language. You will use lists more than any other type, so the few minutes you spend understanding how they work under the hood will save you from the bugs that catch almost every beginner.

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 17 minutes

Remember the memory model from the variables tutorial? Lists take it one step further. A list does not store your values directly. It stores references that point to the real objects sitting elsewhere in memory. When you slice a list, you get a brand new list whose slots point to those same objects. That single fact explains almost everything surprising about lists, and it matters the very first time you share a list between two variables.

Creating Lists

Slicing: scores 1 to 3list object (mutable)id: 0x7fa100[0][1][2][3][4]str‘Rahul’int28str‘Pune’float5.9boolTrueNew list objectid: 0x7fa200[0][1]Key: Slicing creates anew list but shares thesame element objectsPython Lists: How Slots Reference Objects and Slicing Shares Them

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows how a Python list really stores your data. Each slot in the list holds a reference (a pointer) to an object that lives somewhere else in memory, not the value itself. That is why one list can mix an int, a str, a float, and a bool at the same time: every slot just holds a pointer, and a pointer does not care what it points at.

This same picture explains the speed of list operations later on. Adding to the end with append() is fast because Python only tacks on one more pointer, while insert(0, x) is slow because every existing pointer has to shuffle over to make room. That gap matters more and more as your data grows.

The simplest way to make a list is to write the items inside square brackets, much like jotting names down on a sticky note. Say you are managing a small project team of four developers: Rahul, Niranjan, Viraj, and Pravin. Here is that team as a list, along with a few other ways to build one, and you will reach for each of them depending on where the data comes from.

📄 create_lists.py: several ways to make a list

# Literal syntax
team = ["Rahul", "Niranjan", "Viraj", "Pravin"]
scores = [95, 88, 72, 91]
empty = []

# Mixed types: valid, but usually a code smell
mixed = ["Sardar", 30, True, 5.9, None]

# list() constructor turns any iterable into a list
chars = list("Python")
numbers = list(range(1, 6))

print(f"Team: {team}")
print(f"Chars: {chars}")
print(f"Numbers: {numbers}")
print(f"Length: {len(team)}")
print(f"Type: {type(team)}")

▶ Output

Team: ['Rahul', 'Niranjan', 'Viraj', 'Pravin']
Chars: ['P', 'y', 't', 'h', 'o', 'n']
Numbers: [1, 2, 3, 4, 5]
Length: 4
Type: <class 'list'>

What happened here: The square brackets built a list directly from literal values, the most common way you will ever create one. list("Python") walked through the string one character at a time and dropped each into its own slot, and list(range(1, 6)) did the same for the numbers 1 through 5. The mixed list proves Python is happy to hold a string, an int, a bool, a float, and even None side by side, although mixing types like that usually means your data wants a different shape (a dictionary or a small class). Notice len(team) reports 4, the number of items, and type(team) confirms you are holding a real list object.

Indexing: Accessing Elements

The team just picked up a fifth developer, Sardar. To grab a single item from the list, you give Python its position number in square brackets. Here is the one thing that trips up every beginner: counting starts at 0, not 1. So the first item is team[0], the second is team[1], and so on. Think of it like the ground floor in a building: the first floor you walk into is floor zero, and you count up from there.

📄 indexing.py: zero-based positions

team = ["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"]

print(f"First: {team[0]}")
print(f"Second: {team[1]}")
print(f"Last: {team[4]}")

# Change an item in place, because lists are mutable
team[2] = "Prathamesh"
print(f"After replacement: {team}")

# Check membership
print(f"'Viraj' in team: {'Viraj' in team}")
print(f"'Prathamesh' in team: {'Prathamesh' in team}")

▶ Output

First: Rahul
Second: Niranjan
Last: Sardar
After replacement: ['Rahul', 'Niranjan', 'Prathamesh', 'Pravin', 'Sardar']
'Viraj' in team: False
'Prathamesh' in team: True

What happened here: The five names sit at positions 0 through 4, so team[4] is the last one. Because lists are mutable, the line team[2] = "Prathamesh" reached into slot 2 and swapped Viraj out for a new teammate named Prathamesh, all without rebuilding the list. That is exactly why the membership check now says 'Viraj' in team is False: Viraj is gone, and Prathamesh has taken his slot. The in keyword is the clean, readable way to ask “is this value anywhere in the list?” without writing a loop yourself.

Negative Indexing

Python lets you count from the back of the list using negative numbers. colors[-1] is the last item, colors[-2] is the second to last, and so on. Picture a line of people at a bus stop: instead of counting from the front, you can just point at “the last one” or “the second from the end.” No need to know how long the line is.

📄 negative_index.py: count from the end

colors = ["red", "green", "blue", "yellow", "purple"]

print(f"Last: {colors[-1]}")
print(f"Second to last: {colors[-2]}")
print(f"First: {colors[-5]}")  # same as colors[0]

# Useful for getting the last N elements
print(f"Last 3: {colors[-3:]}")

▶ Output

Last: purple
Second to last: yellow
First: red
Last 3: ['blue', 'yellow', 'purple']

What happened here: Negative indices save you from writing colors[len(colors) - 1] every time you want the last item. The list has five colors, so colors[-5] lands right back on the first one, the same place as colors[0]. The real workhorse is the last line: colors[-3:] grabs the last three items in one short, readable step. You will use that pattern constantly, for example to show the three most recent entries in a log or feed.

Slicing

Slicing pulls out a whole section of a list in one go, like cutting a piece from a loaf of bread: you decide where the cut starts and where it stops, you get that piece in your hand, and the loaf itself stays whole. The syntax is list[start:stop:step]. Python includes the start position but stops just before stop, which feels odd at first but quickly becomes second nature. Any of the three parts can be left out, and each one has a sensible default.

📄 slicing.py: list[start:stop:step]

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(f"First 3: {nums[:3]}")
print(f"Index 2 to 5: {nums[2:6]}")
print(f"From index 5: {nums[5:]}")
print(f"Every 2nd: {nums[::2]}")
print(f"Reversed: {nums[::-1]}")
print(f"Last 4: {nums[-4:]}")

# Slicing creates a NEW list
original = [1, 2, 3, 4, 5]
copy = original[:]
copy[0] = 999
print(f"\nOriginal: {original}")
print(f"Copy: {copy}")

▶ Output

First 3: [0, 1, 2]
Index 2 to 5: [2, 3, 4, 5]
From index 5: [5, 6, 7, 8, 9]
Every 2nd: [0, 2, 4, 6, 8]
Reversed: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
Last 4: [6, 7, 8, 9]

Original: [1, 2, 3, 4, 5]
Copy: [999, 2, 3, 4, 5]

What happened here: Each slice handed back a fresh list. nums[:3] took the first three items, nums[2:6] took positions 2 through 5 (remember, the stop is excluded), and the step tricks did the rest: nums[::2] kept every second item, and nums[::-1] walked the list backwards to reverse it. The last part is the important one. original[:] made a shallow copy, a brand new list that holds references to the same elements.

Changing the copy’s first slot to 999 left the original untouched, because the two lists are now separate objects. This works cleanly here only because the elements are integers, which can never be changed in place. The moment a list holds mutable items like nested lists, this same shallow copy turns into the trap we cover in the catch section.

List Operations

Combining lists feels a lot like stapling two shopping lists together before you head to the market: one motion, one longer list. That is all the + operator does. Lists also play nicely with Python’s built-in functions: you can repeat a list with *, and hand one straight to functions like min(), max(), sum(), and sorted().

📄 operations.py: joining, repeating, and built-in functions

# Concatenation
a = [1, 2, 3]
b = [4, 5, 6]
c = a + b
print(f"Concatenated: {c}")

# Repetition
zeros = [0] * 5
print(f"Repeated: {zeros}")

# Built-in functions
scores = [72, 88, 95, 61, 84]
print(f"min: {min(scores)}, max: {max(scores)}, sum: {sum(scores)}")
print(f"Average: {sum(scores) / len(scores):.1f}")
print(f"Sorted: {sorted(scores)}")
print(f"Reversed sorted: {sorted(scores, reverse=True)}")

▶ Output

Concatenated: [1, 2, 3, 4, 5, 6]
Repeated: [0, 0, 0, 0, 0]
min: 61, max: 95, sum: 400
Average: 80.0
Sorted: [61, 72, 84, 88, 95]
Reversed sorted: [95, 88, 84, 72, 61]

What happened here: The + operator built a third list by joining a and b, while [0] * 5 gave a quick way to make a list of five zeros (handy for setting up counters or a fixed-size grid). The built-in functions did the heavy lifting on the scores: sum() / len() is the everyday recipe for an average, and sorted() returned a new sorted list without disturbing the original. Pass reverse=True and you get the highest score first, which is all you need for a quick leaderboard.

Nested Lists

A list can hold other lists. That is how you build a grid, a table, or any data with rows and columns. It is like a spreadsheet: the outer list is the sheet, and each inner list is one row. In the second example below, three students named Anvi, Anvay, and Aviraj each get one row holding their name, age, and score.

📄 nested.py: lists inside lists

# 2D grid: a list of lists
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

print(f"Row 0: {matrix[0]}")
print(f"Element [1][2]: {matrix[1][2]}")

# Student records
students = [
    ["Anvi", 28, 95],
    ["Anvay", 32, 88],
    ["Aviraj", 27, 92],
]
for name, age, score in students:
    print(f"{name} (age {age}): {score}")

▶ Output

Row 0: [1, 2, 3]
Element [1][2]: 6
Anvi (age 28): 95
Anvay (age 32): 88
Aviraj (age 27): 92

What happened here: To reach a single cell in a grid, you use two sets of brackets: matrix[1][2] means “row 1, then item 2 in that row,” which is the 6. The student records show why nested lists feel so natural: each inner list is one person’s record, and the line for name, age, score in students unpacks all three values of a row at once into three friendly names. No index juggling, just clean code that reads almost like English.

The Catch: Shallow Copy Trap

This is the one mistake that catches almost everyone the first time they build a grid. It looks completely innocent, and the bug hides until you change a single cell and watch a whole column light up. Here is the broken code, the surprising output, and then the fix.

📄 shallow_copy.py: the bug that bites when a list holds mutable items

# The trap with [[0]*3]*3
grid = [[0] * 3] * 3
print(f"Before: {grid}")
grid[0][0] = 99
print(f"After:  {grid}")   # ALL rows changed!

# Why: * 3 makes 3 references to the SAME inner list
print(f"Same object? {grid[0] is grid[1]}")  # True!

# The fix: a comprehension builds a separate list each time
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 99
print(f"\nFixed: {grid}")  # only row 0 changed

▶ Output

Before: [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
After:  [[99, 0, 0], [99, 0, 0], [99, 0, 0]]
Same object? True

Fixed: [[99, 0, 0], [0, 0, 0], [0, 0, 0]]

What happened here: [[0] * 3] * 3 did not make three separate rows. It made one row, then pointed three slots at that same row. So when you set grid[0][0] = 99, all three slots saw the change, because there is really only one inner list. The grid[0] is grid[1] check confirms it: they are the exact same object. Think of a shared Google Doc link pasted into three chats.

It looks like three documents, but everyone is editing the same file, so one edit shows up everywhere. The fix, [[0] * 3 for _ in range(3)], runs the loop three times and builds a fresh inner list on each pass, giving you three independent rows. Now editing row 0 leaves the others alone.

When You Will Reach for a List

Lists are not an abstract idea you learn once and forget. They show up the moment you write anything real. Here are three everyday situations where a list is exactly the right tool.

  • Reading a file line by line. When you open a text file or a CSV (Comma-Separated Values) file, you usually end up with a list of rows. You loop over that list, clean each row, and maybe slice off the header with rows[1:].
  • Collecting results in a loop. Start with an empty list, then add to it as you go: gather every user who passed a check, every price above a threshold, every filename that matches a pattern. The list grows as the loop runs, and you have your answer at the end.
  • Holding ordered data where position matters. A queue of tasks, the moves in a game, the steps in a recipe, the last ten messages in a chat. Anywhere order counts and the contents change, a list fits.

Common Mistakes

Mistake 1: IndexError from off-by-one

🚫 Crash

names = ["Rahul", "Aditi", "Pravin"]
print(names[3])   # IndexError: list index out of range
# Valid indices: 0, 1, 2 (or -1, -2, -3)

Why: A list of three items has positions 0, 1, and 2. There is no position 3, so asking for names[3] raises IndexError: list index out of range. The fix is to remember that the last valid index is always len(names) - 1, or just use names[-1] when you want the last item and let Python do the counting.

Mistake 2: Using = instead of [:] for copying

🚫 Not a copy, just a second label

a = [1, 2, 3]
b = a          # b points to the SAME list
b.append(4)
print(a)       # [1, 2, 3, 4], a changed too!

✅ Proper copy

a = [1, 2, 3]
b = a[:]       # shallow copy, a brand new list object
b.append(4)
print(a)       # [1, 2, 3], a is unchanged

Why: Writing b = a does not copy the list. It just sticks a second label on the same list, so anything you do through b also shows up in a. When you truly want a separate copy, use a[:] or a.copy(). Both build a new list object that you can change without touching the original.

Best Practices

  • DO use list[:] or list.copy() for shallow copies
  • DO use negative indexing for last elements: items[-1]
  • DO use in for membership tests: if x in my_list:
  • DO use list comprehensions for creating 2D grids: [[0]*n for _ in range(m)]
  • DON’T use [[0]*n]*m for 2D grids, since it creates shared references
  • DON’T use b = a when you want an independent copy

Conclusion

Lists are ordered, mutable, and happy to hold any type. Indexing starts at 0, and negative indices count from the end. Slicing always hands back a new list (a shallow copy). The biggest catch is that plain assignment with b = a gives you a second label on the same list, not a copy, so use a[:] or a.copy() when you need a list you can change on its own.

Next up: List Methods, every method from append to sort, grouped by what you are trying to do. Lists keep coming back through the rest of the series. List comprehensions give you a one-liner for building lists, generators let you process list-like data without loading it all into memory at once, and in Part 4 you will trade Python lists for NumPy arrays that run many times faster on big number crunching. Want to jump around or revisit an earlier topic? Head over to the Python + AI/ML tutorial series home for the full index.

Practice Exercises

  1. Exercise 1: Create a list of 5 fruits. Print first, last, and middle elements.
  2. Exercise 2: Store 5 user numbers in a list. Print sum, average, min, max.
  3. Exercise 3: Implement a stack with push, pop, peek, is_empty. Process 10 operations.

Frequently Asked Questions

What is a list in Python?

A list is an ordered, mutable collection that can hold items of any type. Created with square brackets: [1, 2, 3]. Lists support indexing, slicing, and have built-in methods for adding, removing, and sorting items.

What is the difference between a list and a tuple?

Lists are mutable, so you can add, remove, and change elements after creation. Tuples are immutable, so once created they cannot be changed. Lists use [], tuples use (). Use lists when data needs to change; use tuples for fixed data like coordinates or database records.

How does negative indexing work in Python?

Negative indices count from the end of the list. list[-1] is the last element, list[-2] is second-to-last, and so on. It’s equivalent to list[len(list) - 1] but more readable.

What is the difference between slicing and indexing?

Indexing (list[0]) returns a single element. Slicing (list[0:3]) returns a new list containing elements from start to stop-1. Slicing always creates a new list object; indexing gives you a reference to an existing element.

How do I copy a list in Python?

Use new = old[:], new = old.copy(), or new = list(old) for a shallow copy. For nested lists, use import copy; new = copy.deepcopy(old) to copy the inner objects too.

Interview Questions on Python Lists

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: Why can a single Python list hold an int, a string, and a float at the same time?

Because a list never stores values directly. Each slot holds a reference to an object living elsewhere in memory, and a reference does not care what type it points to. The list only manages pointers, while the objects themselves carry their own types. That said, mixing types in one list is usually a sign the data wants a dictionary or a small class instead.

Q: You create a 3×3 grid with grid = [[0] * 3] * 3, set grid[0][0] = 99, and suddenly every row shows 99. What went wrong, and how do you fix it?

The outer * 3 did not build three rows. It created three references to the same inner list, so a change through one row is visible through all of them. You can prove it with grid[0] is grid[1], which returns True. The fix is a comprehension, [[0] * 3 for _ in range(3)], which runs the loop three times and builds a fresh inner list on every pass.

Q: A teammate writes backup = data before modifying data, but later finds the backup has all the modifications too. What happened, and what should they have written?

Assignment never copies a list. backup = data just attached a second name to the same list object, so every change made through data was visible through backup. They should have written backup = data[:] or backup = data.copy() to get a new list object. And if data holds nested lists that will also change, only copy.deepcopy(data) gives a fully independent backup, because a shallow copy still shares the inner lists.

Q: What happens if a slice runs past the end of a list, like nums[2:100] on a 10-item list?

Nothing breaks. Slicing is forgiving: Python clamps the indices to the list’s real bounds and returns whatever exists in that range, so nums[2:100] simply gives you everything from index 2 to the end. This is a deliberate contrast with plain indexing, where nums[100] raises an IndexError immediately.

Q: Does nums[::-1] reverse the list in place?

No. It is a slice with step -1, so it builds and returns a brand new reversed list while leaving nums untouched. To reverse the original list in place, call nums.reverse(), which modifies the list itself and returns None. Pick based on whether you still need the original order afterwards.

Q: Why is append() fast, but insert(0, x) gets slower and slower as the list grows?

A list keeps its element references in one contiguous block of memory, so append() usually just writes one more reference at the end. insert(0, x) has to shift every existing reference one position to the right to make room at the front, which takes time proportional to the length of the list. When you need fast additions and removals at both ends, collections.deque is the right tool instead of a list.

Go deeper: when you outgrow this post, the official Python documentation is the next stop.

Previous: Python: Loop Practice, Building Patterns, Games, and Visual Output

Next: Python: List Methods, append to sort Every Method Explained

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *