DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

Python Lists: Creation, Indexing, Slicing, Methods

If there's one data structure you'll reach for constantly in Python, it's the list. python lists are ordered, flexible, and endlessly practical — and this article covers the full picture: how to create them, how to access and slice their contents, and the essential python list methods you'll use in nearly every program you write.

Introduction: What Makes Lists Python's Go-To Data Structure

A list is an ordered, mutable collection that can hold a mix of different data types all at once.

mixed = [1, "two", 3.0, True]

That single example already shows two of a list's defining traits: order (the items stay in the sequence you put them in) and flexibility (an integer, a string, a float, and a Boolean are all sitting in the same list without any complaint from Python).

Lists vs. tuples: why mutability matters

If you've encountered tuples elsewhere, the core distinction is mutability. A list can be changed after creation — items added, removed, or replaced. A tuple, once created, is locked — its contents can't change. This one difference shapes when you'd reach for each: lists are the default choice when you expect a collection to grow, shrink, or get reordered over the course of your program; tuples fit better when the contents are meant to stay fixed (a topic covered in more depth in a later article in this series).

This article covers four areas in turn: creating lists, indexing into them, slicing out portions, and the methods that let you modify and search them.

Creating Lists

Literal syntax

The most common, most readable way to create a list is the literal syntax — square brackets with comma-separated values:

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

This is generally the preferred approach over the alternative constructor covered next — it's more concise, and it's also slightly faster in practice, since Python doesn't need to go through the extra step of calling a function to build it.

The list() constructor

list() builds a list from any iterable — useful specifically when you're converting something else into a list:

# From a string — splits into individual characters
letters = list("hello")
print(letters)   # ['h', 'e', 'l', 'l', 'o']

# From a tuple
numbers = list((1, 2, 3))
print(numbers)   # [1, 2, 3]

# From a range
sequence = list(range(5))
print(sequence)   # [0, 1, 2, 3, 4]
Empty lists and repeated elements
empty = []            # an empty list, ready to be filled later
also_empty = list()   # equivalent, using the constructor

zeros = [0] * 5
print(zeros)   # [0, 0, 0, 0, 0]

That * repetition pattern is a quick, common way to pre-fill a list of a known size with a default value — useful when you know how many slots you'll need before you know what should go in them.

A preview: list comprehensions

Python also supports a compact syntax for building a list based on some expression and condition, called a list comprehension:

squares = [x ** 2 for x in range(5)]
print(squares)   # [0, 1, 4, 9, 16]

List comprehensions are genuinely useful and worth knowing exist, but they're a big enough topic to deserve their own dedicated treatment later in this series — for now, just recognize the syntax if you encounter it.

Indexing: Accessing List Elements

Positive and negative indexing

Lists support the same two-directional indexing covered in the earlier article on string slicing: positive indices count from 0 at the start, negative indices count from -1 at the end.

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

print(fruits[0])    # apple
print(fruits[-1])   # date
print(fruits[2])    # cherry
Why negative indexing is genuinely useful

Negative indexing isn't just a shortcut — it's particularly valuable when you don't know (or don't want to hardcode) a list's exact length. fruits[-1] always gets you the last item, regardless of how long the list is, without needing to calculate fruits[len(fruits) - 1] yourself.

recent_scores = [88, 92, 79, 95, 87]
print(f"Most recent score: {recent_scores[-1]}")   # 87
IndexError: a common source of bugs

Accessing a position that doesn't exist raises an error, exactly as it does with string indexing:

fruits = ["apple", "banana"]
print(fruits[5])
# IndexError: list index out of range

This is a genuinely common source of bugs in real, production code — especially when accessing an index calculated from user input, a database query, or some other source that might not always return as many items as expected. A safe-access pattern using try/except handles this gracefully:

fruits = ["apple", "banana"]

try:
    print(fruits[5])
except IndexError:
    print("That position doesn't exist in the list")

Slicing: Extracting Sublists

Basic syntax

Slicing lists works exactly the way string slicing does, using [start:stop:step], with all three parts optional:

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

print(numbers[2:5])    # [2, 3, 4]
print(numbers[:3])     # [0, 1, 2] — from the start
print(numbers[7:])     # [7, 8, 9] — to the end
print(numbers[:])      # a full copy of the entire list

That last example — numbers[:] — is a common, quick way to create a shallow copy of a list, distinct from the original object.

Using step to skip elements or reverse
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(numbers[::2])    # [0, 2, 4, 6, 8] — every second item
print(numbers[::-1])   # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] — the whole list, reversed

[::-1] is the same reversing idiom covered earlier for strings — no start or stop specified, and a step of -1 walking backward through the entire list.

Slice assignment

This is where lists genuinely diverge from strings: because lists are mutable, you can assign directly into a slice to replace, insert, or delete multiple elements at once, in place.

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

# Replace a range of elements
numbers[1:3] = [10, 20]
print(numbers)   # [0, 10, 20, 3, 4]

# Insert elements without removing anything, by targeting an empty slice
numbers = [0, 1, 2]
numbers[1:1] = [100, 200]
print(numbers)   # [0, 100, 200, 1, 2]

# Delete a range of elements by assigning an empty list to the slice
numbers = [0, 1, 2, 3, 4]
numbers[1:3] = []
print(numbers)   # [0, 3, 4]

This is a genuinely powerful capability — a single line can restructure a meaningful chunk of a list, something strings simply can't do given their immutability.

5. Essential List Methods

Adding elements
fruits = ["apple", "banana"]

# append() — adds a single item to the end
fruits.append("cherry")
print(fruits)   # ['apple', 'banana', 'cherry']

# insert() — adds an item at a specific position
fruits.insert(1, "avocado")
print(fruits)   # ['apple', 'avocado', 'banana', 'cherry']

# extend() — adds each item from another iterable individually
fruits.extend(["date", "fig"])
print(fruits)   # ['apple', 'avocado', 'banana', 'cherry', 'date', 'fig']

Why extend() differs from append() with a list argument — this is a genuinely common point of confusion. If you append() a list, the entire list gets added as a single nested item, not merged in:

fruits = ["apple", "banana"]

fruits.append(["cherry", "date"])
print(fruits)   # ['apple', 'banana', ['cherry', 'date']] — a nested list!

fruits = ["apple", "banana"]
fruits.extend(["cherry", "date"])
print(fruits)   # ['apple', 'banana', 'cherry', 'date'] — items merged in individually

append() always adds exactly one new item, no matter what you pass it. extend() iterates over whatever you pass it and adds each element individually. Mixing these up is a genuinely common bug — if your list unexpectedly contains a nested list where you didn't intend one, this is the first thing to check.

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

# remove() — removes the first matching value
fruits.remove("banana")
print(fruits)   # ['apple', 'cherry', 'banana']

# pop() — removes and returns an item by index (defaults to the last item)
last = fruits.pop()
print(last, fruits)   # banana ['apple', 'cherry']

first = fruits.pop(0)
print(first, fruits)   # apple ['cherry']

# del — removes an item (or slice) by index, without returning it
numbers = [0, 1, 2, 3, 4]
del numbers[1]
print(numbers)   # [0, 2, 3, 4]

# clear() — removes everything, leaving an empty list
numbers.clear()
print(numbers)   # []

remove() and pop() behave differently in a subtle but important way: remove() searches for a value and removes its first occurrence (raising a ValueError if it's not found), while pop() operates on a position and hands back the removed item — genuinely useful when you need to both extract and discard an item in one step.

Ordering and searching
numbers = [5, 2, 8, 1, 9]

# sort() — sorts the list in place, returns None
numbers.sort()
print(numbers)   # [1, 2, 5, 8, 9]

numbers.sort(reverse=True)
print(numbers)   # [9, 8, 5, 2, 1]

# reverse() — reverses the list in place
numbers.reverse()
print(numbers)   # [1, 2, 5, 8, 9]

# index() — finds the position of the first matching value
fruits = ["apple", "banana", "cherry"]
print(fruits.index("banana"))   # 1

# count() — counts how many times a value appears
numbers = [1, 2, 2, 3, 2]
print(numbers.count(2))   # 3
Mutating methods vs. functions that return a new list

This is worth being explicit about, since it trips people up regularly: sort() and reverse() are methods that mutate the list in place — they modify the original list and return None. If you try to capture their result directly, you'll get None, not the sorted list:

numbers = [3, 1, 2]
result = numbers.sort()
print(result)    # None — sort() doesn't return the sorted list
print(numbers)   # [1, 2, 3] — the original list was modified directly

If you want a new, sorted list without modifying the original, use the built-in sorted() function instead — this is a function, not a method, and it returns a fresh list rather than mutating anything:

numbers = [3, 1, 2]
new_list = sorted(numbers)

print(numbers)     # [3, 1, 2] — unchanged
print(new_list)    # [1, 2, 3] — a separate, new sorted list

The same distinction applies to reversed() (a function returning a new iterator) versus .reverse() (a method mutating in place). As a general rule in Python: methods that end without returning anything meaningful (None) are usually mutating the object in place, while standalone built-in functions typically return something new, leaving the original untouched. Keeping that distinction straight will save you from a whole category of "why is my variable suddenly None" bugs.

PREVIOUSNEXT LESSON