Introduction

You already know lists and dicts. This article is about when NOT to use them — and what to reach for instead.

Python's four built-in data structures are tuned for different access patterns. Pick wrong and a millisecond operation becomes minutes at scale. The collections module — defaultdict, Counter, namedtuple, deque — fills the gaps where built-ins fall short. Most people underuse it badly.

Each section covers one structure and the problems it is good at, and the final section works backwards from the problem to the structure. Because nobody thinks "I need a deque today." They think "why is this queue so slow."

Lists and When They Shine

You know this. Ordered, mutable, dynamic arrays under the hood.

Python
# Basic list operations
fruits = ["apple", "banana", "cherry", "date"]

# Append adds to the end -- O(1) amortized
fruits.append("elderberry")

# Insert at a specific position -- O(n) because elements shift
fruits.insert(0, "avocado")

# Remove by value -- O(n) linear scan
fruits.remove("banana")

# Pop from end -- O(1), pop from index -- O(n)
last = fruits.pop()
first = fruits.pop(0)

# Check membership -- O(n) linear scan
if "cherry" in fruits:
    print("Found it!")

The performance differences between these operations matter more than most people expect. Appending to the end is cheap — Python over-allocates memory so it doesn't resize on every call. Inserting at the beginning? Every element shifts right. O(n). If you're building a queue with insert(0, item) and pop(), stop. Use deque.

Slicing: More Powerful Than You Think

Full syntax: list[start:stop:step]. All three optional. Most people stop at list[start:stop] and miss the good stuff.

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

# Get every other element
evens = numbers[::2]  # [0, 2, 4, 6, 8]
odds = numbers[1::2]  # [1, 3, 5, 7, 9]

# Reverse a list (creates a new copy)
reversed_nums = numbers[::-1]  # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# Get the last three elements
tail = numbers[-3:]  # [7, 8, 9]

# Slice assignment -- replace a chunk in place
numbers[2:5] = [20, 30, 40, 50]
print(numbers)  # [0, 1, 20, 30, 40, 50, 5, 6, 7, 8, 9]

# Delete a slice
del numbers[2:6]
print(numbers)  # [0, 1, 5, 6, 7, 8, 9]

Stop writing loops to copy portions of a list. A slice returns a shallow copy in one expression.

List Comprehensions

Optimized at the bytecode level. Faster than equivalent for-loops. And once the syntax clicks, more readable too.

Python
# Transform and filter in one expression
raw_prices = ["$12.50", "$8.99", "invalid", "$45.00", "", "$3.25"]
clean_prices = [
    float(p.replace("$", ""))
    for p in raw_prices
    if p.startswith("$")
]
# Result: [12.5, 8.99, 45.0, 3.25]

# Nested comprehension -- flattening a matrix
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
# Result: [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Dictionary comprehension (yes, those exist too)
word_lengths = {word: len(word) for word in ["python", "data", "structures"]}
# Result: {'python': 6, 'data': 4, 'structures': 10}

A word of warning: don't nest three levels deep. If yours spans more than two lines, use a loop. Readability at 2 AM matters more than saving two lines.

Tuples and Immutability

Same as lists but frozen. No appending, no removing, no modifying in place.

Why would you want that constraint? Safety — a tuple holding your database config can't be accidentally modified at runtime. Hashability — tuples work as dictionary keys and set members, lists don't. Performance — slightly faster to create and iterate because Python can optimize the memory layout knowing the size won't change. Three reasons, all good.

Tuple Unpacking

Function returns, loop variables, swap operations. Once you internalize unpacking it shows up everywhere.

Python
# Basic unpacking
coordinates = (40.7128, -74.0060)
lat, lon = coordinates

# Swap without a temp variable
a, b = 1, 2
a, b = b, a  # a=2, b=1

# Star unpacking -- grab the rest
first, *middle, last = [1, 2, 3, 4, 5]
# first=1, middle=[2, 3, 4], last=5

# Unpacking in loops
users = [
    ("alice", "alice@example.com", "admin"),
    ("bob", "bob@example.com", "editor"),
    ("carol", "carol@example.com", "viewer"),
]
for name, email, role in users:
    print(f"{name} ({role}): {email}")


# Returning multiple values from a function
def get_stats(data):
    return min(data), max(data), sum(data) / len(data)


low, high, avg = get_stats([4, 8, 15, 16, 23, 42])
Terminal running python unpacking.py: three lines, alice (admin), bob (editor) and carol (viewer), each followed by the email address.
Real output. The loop unpacks each user tuple straight into name, email and role.

Named Tuples: The Best of Both Worlds

What does point[0] mean? X or y? Nobody remembers. Named tuples fix this — each position gets a name, all the performance stays.

Python
from collections import namedtuple
from typing import NamedTuple

# Classic approach
Point = namedtuple("Point", ["x", "y", "z"])
origin = Point(0, 0, 0)


# Modern approach with type hints (Python 3.6+)
class ServerConfig(NamedTuple):
    host: str
    port: int
    debug: bool = False


config = ServerConfig(host="localhost", port=8080)
print(config.host)  # "localhost"
print(config.port)  # 8080
print(config.debug)  # False (default value)

# Still works like a regular tuple
host, port, debug = config
print(len(config))  # 3
Terminal running python named_tuples.py: prints localhost, 8080, False and 3.
Real output. Named fields read like attributes, the default fills in debug, and len() still works like a tuple.

Good for API response wrappers, config objects, anywhere you'd write a small class with no methods. Lighter than full classes. Type checkers handle them fine. When you create millions of small objects, per-instance memory matters: a regular class or dataclass stores attributes in a per-instance __dict__, while a named tuple stores them in a fixed tuple. @dataclass(slots=True) (Python 3.10+) removes the __dict__ too and closes most of that gap. Measure with your own objects (for example with tracemalloc) before switching.

Need named fields and mutability? dataclasses (Python 3.7+). Default values, auto-generated __init__, __repr__, __eq__.

Dictionaries for Fast Lookups

Module namespaces? Dictionaries. Class attributes? Dictionaries. JSON parsing output? Dictionaries. Python runs on dictionaries. Hash tables underneath — key lookups, insertions, deletions all O(1) average.

Since Python 3.7, insertion order is guaranteed by the language spec. You almost never need OrderedDict anymore. Some patterns beyond the basics:

Python
# The .get() method prevents KeyError
user_prefs = {"theme": "dark", "language": "en"}
font_size = user_prefs.get("font_size", 14)  # Returns 14 (default)

# .setdefault() -- get or initialize in one call
categories = {}
for product in [("laptop", "electronics"), ("shirt", "clothing"), ("phone", "electronics")]:
    name, cat = product
    categories.setdefault(cat, []).append(name)
# {'electronics': ['laptop', 'phone'], 'clothing': ['shirt']}

# Merge dictionaries (Python 3.9+)
defaults = {"timeout": 30, "retries": 3, "verbose": False}
overrides = {"timeout": 60, "verbose": True}
final_config = defaults | overrides
# {'timeout': 60, 'retries': 3, 'verbose': True}

# Dict comprehension with conditional logic
scores = {"Alice": 92, "Bob": 67, "Carol": 85, "Dave": 58, "Eve": 91}
passing = {name: score for name, score in scores.items() if score >= 70}
# {'Alice': 92, 'Carol': 85, 'Eve': 91}

Writing loops with setdefault or manual key-checking to group items? defaultdict is what you want.

The dict | other_dict merge syntax from 3.9 looks clean in two-dict examples. Three or four dicts with conflicting keys? The precedence gets confusing. I still prefer {**defaults, **overrides}. Older style, but the merge order is visually obvious.

Keys must be hashable. Strings, numbers, tuples of hashable items, frozensets — fine. Lists, dicts, sets — TypeError. Hash tables need a stable hash value and mutable objects can't give one.

Sets for Unique Collections

Underused. Criminally so.

Codebases everywhere maintaining uniqueness with lists and if item not in my_list checks. O(n) per check. A set does the same thing in O(1). Internally a set is a hash table of keys, much like a dict's but with its own implementation and no values. But uniqueness is only half the story. Set operations — intersection, union, difference, symmetric difference — are where sets actually earn their keep.

Python
# Real-world example: permission management
user_permissions = {"read", "write", "delete"}
required_permissions = {"read", "write", "admin"}

# What permissions does the user have that are required?
granted = user_permissions & required_permissions  # {'read', 'write'}

# What required permissions is the user missing?
missing = required_permissions - user_permissions  # {'admin'}

# All permissions combined
all_perms = user_permissions | required_permissions  # {'read', 'write', 'delete', 'admin'}

# Permissions in one set but not both (symmetric difference)
exclusive = user_permissions ^ required_permissions  # {'delete', 'admin'}

# Check if one set is a subset/superset of another
print(user_permissions >= required_permissions)  # False
print({"read"} <= user_permissions)  # True (subset check)

# Deduplication while preserving order (Python 3.7+)
items = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
unique_ordered = list(dict.fromkeys(items))
# [3, 1, 4, 5, 9, 2, 6]

That permission pattern? Everywhere in real applications. Clean, expressive, constant time regardless of how many permissions you're checking. And sets are mutable, so they can't be dictionary keys. For an immutable version, frozenset. Same operations, read-only after creation.

The Collections Module

Three things from collections come up constantly. The rest you can learn when you need them.

defaultdict: No More KeyError

How many times have you written this?

Python
# The manual way -- checking before inserting
word_count = {}
for word in words:
    if word not in word_count:
        word_count[word] = 0
    word_count[word] += 1

defaultdict takes a factory function that creates a default value for missing keys. Done.

Python
from collections import defaultdict, Counter, deque

# defaultdict with int -- perfect for counting
word_count = defaultdict(int)
for word in "the quick brown fox jumps over the lazy dog the fox".split():
    word_count[word] += 1
# defaultdict(int, {'the': 3, 'quick': 1, 'brown': 1, ...})

# defaultdict with list -- perfect for grouping
students_by_grade = defaultdict(list)
roster = [("Alice", "A"), ("Bob", "B"), ("Carol", "A"), ("Dave", "C"), ("Eve", "B")]
for name, grade in roster:
    students_by_grade[grade].append(name)
# {'A': ['Alice', 'Carol'], 'B': ['Bob', 'Eve'], 'C': ['Dave']}

Counter: Counting Made Trivial

defaultdict(int) handles counting. Counter goes further — purpose-built for tallying, ships with methods you'd otherwise write by hand.

Python
from collections import Counter

# Count occurrences in any iterable
log_levels = ["INFO", "ERROR", "INFO", "WARNING", "ERROR", "INFO",
              "DEBUG", "ERROR", "INFO", "WARNING"]
counts = Counter(log_levels)
print(counts)
# Counter({'INFO': 4, 'ERROR': 3, 'WARNING': 2, 'DEBUG': 1})

# Get the N most common elements
print(counts.most_common(2))
# [('INFO', 4), ('ERROR', 3)]

# Arithmetic with Counters
morning_errors = Counter({"404": 12, "500": 3, "403": 7})
afternoon_errors = Counter({"404": 8, "500": 15, "502": 4})
total = morning_errors + afternoon_errors
# Counter({'404': 20, '500': 18, '403': 7, '502': 4})

# Find elements unique to the morning
morning_only = morning_errors - afternoon_errors
# Counter({'404': 4, '403': 7})
Python REPL session: Counter of log levels with INFO 4 and ERROR 3, most_common(2) returning INFO and ERROR, the sum of morning and afternoon errors with 404 at 20, and the difference keeping 403: 7 and 404: 4.
Real output. Counter in the REPL: counts, most_common(2), and addition and subtraction of two Counters.

Log analysis, data profiling, frequency distributions. The most_common() method alone saves a lot of code compared to sorting a dict by values manually.

deque: The Double-Ended Queue

Need a queue? This is it. O(1) append and pop on both ends. Lists are O(n) at the front — and that's the whole reason deque exists.

Also great for sliding windows, recent-history buffers, BFS. Set maxlen for a fixed-size buffer that drops old items automatically.

Python
from collections import deque

# Fixed-size buffer for recent log entries
recent_logs = deque(maxlen=5)
for i in range(10):
    recent_logs.append(f"Log entry {i}")
print(list(recent_logs))
# ['Log entry 5', 'Log entry 6', 'Log entry 7', 'Log entry 8', 'Log entry 9']

# Use as a queue (FIFO)
task_queue = deque(["task_a", "task_b", "task_c"])
task_queue.append("task_d")  # Add to right end
next_task = task_queue.popleft()  # Remove from left end -- O(1)!
Terminal running python deque_demo.py: prints a list of Log entry 5 through Log entry 9.
Real output. With maxlen=5 the deque keeps only the last five log entries.

Choosing the Right Data Structure

Ask in order:

  1. Key-value pairs? Dict. (defaultdict for grouping/counting.)
  2. Uniqueness or membership checks? Set. (frozenset if it needs to be hashable.)
  3. Ordered and immutable? Tuple. (namedtuple if readability matters.)
  4. Queue or double-ended operations? deque.
  5. Everything else? List. It's fine.

Time complexities at a glance:

OperationListTupleDictSetdeque
Index lookupO(1)O(1)N/AN/AO(n)
Key/Value lookupN/AN/AO(1)N/AN/A
Membership test (in)O(n)O(n)O(1)O(1)O(n)
Append (end)O(1)*N/AO(1)O(1)O(1)
Append (front)O(n)N/AN/AN/AO(1)
Pop (end)O(1)N/AO(1) (popitem, last inserted)O(1) (arbitrary item)O(1)
Pop (front)O(n)N/AN/AN/AO(1)
Insert (middle)O(n)N/AN/A (no positions)N/A (unordered)O(n)
Delete (by value)O(n)N/AO(1)O(1)O(n)
SortO(n log n)N/AN/AN/AN/A
Memory usageMediumLowHighHighMedium

* Amortized O(1). Occasionally O(n) when the underlying array resizes, but the cost spreads across many operations.

A common example: a pipeline checks each event's user ID against a "seen" list. With tens of thousands of unique users, if user_id in seen_list scans the whole list on every event, so total work grows roughly with events × users. Swap the list for a set and each check is a single hash lookup. Same logic, same result, and the gap widens as the data grows.

Validating a JSON payload has the required fields:

Python
required_fields = {"name", "email", "age"}
provided_fields = set(payload.keys())
missing = required_fields - provided_fields
if missing:
    raise ValueError(f"Missing required fields: {missing}")

Cleaner than a loop. Looks identical whether you have 3 required fields or 300.

Think about what you'll do most often. Building a collection and iterating once? A list is fine, even with duplicates. Checking membership repeatedly? Set or dictionary. Always. And stop returning dictionaries just to pass back two values from a function. A tuple or named tuple is clearer, lighter, and what the standard library itself uses — divmod() returns a tuple for exactly this reason.

Beyond the Built-ins

The built-ins plus collections cover most everyday code. The standard library has two more worth knowing: heapq for priority queues (always pop the smallest item in O(log n)) and bisect for keeping a list sorted as you insert. Past that, sortedcontainers on PyPI gives sorted lists, dicts and sets, and numpy arrays handle large numeric data. You'll know when you need them.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.