Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Python – Dictionaries & Sets

Dictionaries and sets are two of the most powerful and frequently used data structures in Python. Dictionaries give you a fast, flexible way to associate keys with values — think of them as a lookup table where you can instantly retrieve data by its label. Sets give you an unordered collection of unique elements with blazing-fast membership testing. Together, they solve a huge range of real-world programming problems: configuration management, deduplication, counting, caching, grouping, and more. If you are writing Python professionally, you will reach for dicts and sets daily.

In this tutorial, we will cover both data structures thoroughly — from creation and basic operations through advanced patterns like defaultdict, Counter, set algebra, and frozensets. By the end, you will understand not just the syntax, but when and why to choose each structure.

Part 1: Dictionaries

Introduction to Dictionaries

A dictionary (dict) is a mutable, unordered (as of Python 3.7+, insertion-ordered) collection of key-value pairs. Each key must be unique and hashable (strings, numbers, tuples of immutables), and each key maps to exactly one value. Dictionaries are implemented as hash tables, which means lookups, insertions, and deletions all run in O(1) average time — regardless of how many entries the dictionary contains.

Use a dictionary when you need to:

  • Map identifiers to data (user ID to user record, config key to value)
  • Count occurrences of items
  • Group data by category
  • Build lookup tables for fast retrieval
  • Represent structured data (similar to JSON objects)

Creating Dictionaries

There are several ways to create a dictionary in Python. Choose the one that best fits your situation.

Literal syntax (most common)

# Curly braces with key: value pairs
user = {
    "name": "Folau",
    "age": 30,
    "city": "Salt Lake City",
    "is_active": True
}
print(user)
# {'name': 'Folau', 'age': 30, 'city': 'Salt Lake City', 'is_active': True}

The dict() constructor

# From keyword arguments (keys must be valid identifiers)
user = dict(name="Folau", age=30, city="Salt Lake City")
print(user)
# {'name': 'Folau', 'age': 30, 'city': 'Salt Lake City'}

# From a list of tuples
pairs = [("host", "localhost"), ("port", 5432), ("db", "myapp")]
config = dict(pairs)
print(config)
# {'host': 'localhost', 'port': 5432, 'db': 'myapp'}

# From two parallel lists using zip
keys = ["name", "language", "level"]
values = ["Folau", "Python", "Senior"]
profile = dict(zip(keys, values))
print(profile)
# {'name': 'Folau', 'language': 'Python', 'level': 'Senior'}

Dictionary comprehension

# Create a dict of squares
squares = {x: x ** 2 for x in range(1, 6)}
print(squares)
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

dict.fromkeys() — initialize with a default value

# All keys get the same default value
statuses = dict.fromkeys(["Alice", "Bob", "Charlie"], "pending")
print(statuses)
# {'Alice': 'pending', 'Bob': 'pending', 'Charlie': 'pending'}

# Without a default, values are None
placeholders = dict.fromkeys(["name", "email", "phone"])
print(placeholders)
# {'name': None, 'email': None, 'phone': None}

Accessing Values

You can retrieve values from a dictionary using bracket notation or the get() method. The key difference: brackets raise a KeyError if the key does not exist, while get() returns a default value (defaulting to None).

user = {"name": "Folau", "age": 30, "city": "Salt Lake City"}

# Bracket notation
print(user["name"])    # Folau
# print(user["email"])  # KeyError: 'email'

# get() with default - safer for uncertain keys
print(user.get("name"))           # Folau
print(user.get("email"))          # None (no KeyError)
print(user.get("email", "N/A"))   # N/A (custom default)

You can also retrieve all keys, values, or key-value pairs as view objects. These views are dynamic — they reflect changes to the dictionary in real time.

user = {"name": "Folau", "age": 30, "city": "Salt Lake City"}

# Keys
print(user.keys())    # dict_keys(['name', 'age', 'city'])

# Values
print(user.values())  # dict_values(['Folau', 30, 'Salt Lake City'])

# Key-value pairs as tuples
print(user.items())   # dict_items([('name', 'Folau'), ('age', 30), ('city', 'Salt Lake City')])

# Check if a key exists
print("name" in user)    # True
print("email" in user)   # False

Modifying Dictionaries

Dictionaries are mutable. You can add new key-value pairs, update existing ones, and merge dictionaries together.

Add or update a single key

user = {"name": "Folau", "age": 30}

# Add a new key
user["email"] = "folau@example.com"

# Update an existing key
user["age"] = 31

print(user)
# {'name': 'Folau', 'age': 31, 'email': 'folau@example.com'}

update() — merge another dictionary or key-value pairs

config = {"host": "localhost", "port": 5432}

# Merge from another dict (existing keys are overwritten)
config.update({"port": 3306, "database": "myapp"})
print(config)
# {'host': 'localhost', 'port': 3306, 'database': 'myapp'}

# Merge from keyword arguments
config.update(user="admin", password="secret")
print(config)
# {'host': 'localhost', 'port': 3306, 'database': 'myapp', 'user': 'admin', 'password': 'secret'}

setdefault() — set a key only if it does not exist

user = {"name": "Folau", "age": 30}

# Key does not exist - sets it and returns the value
email = user.setdefault("email", "folau@example.com")
print(email)  # folau@example.com
print(user)   # {'name': 'Folau', 'age': 30, 'email': 'folau@example.com'}

# Key already exists - does nothing, returns existing value
name = user.setdefault("name", "Unknown")
print(name)   # Folau (not overwritten)

Merge operator |= (Python 3.9+)

# The | operator creates a new merged dictionary
defaults = {"theme": "dark", "language": "en", "page_size": 25}
overrides = {"theme": "light", "page_size": 50}

final = defaults | overrides
print(final)
# {'theme': 'light', 'language': 'en', 'page_size': 50}

# The |= operator updates in place
defaults |= overrides
print(defaults)
# {'theme': 'light', 'language': 'en', 'page_size': 50}

Removing Items

Python provides several ways to remove entries from a dictionary, each with different behavior.

user = {"name": "Folau", "age": 30, "city": "Salt Lake City", "email": "folau@example.com"}

# del - remove a specific key (raises KeyError if missing)
del user["email"]
print(user)
# {'name': 'Folau', 'age': 30, 'city': 'Salt Lake City'}

# pop() - remove and return the value (with optional default)
age = user.pop("age")
print(age)    # 30
print(user)   # {'name': 'Folau', 'city': 'Salt Lake City'}

# pop() with default avoids KeyError
missing = user.pop("phone", "not found")
print(missing)  # not found

# popitem() - remove and return the last inserted key-value pair
user["role"] = "developer"
user["level"] = "senior"
last = user.popitem()
print(last)   # ('level', 'senior')
print(user)   # {'name': 'Folau', 'city': 'Salt Lake City', 'role': 'developer'}

# clear() - remove all entries
user.clear()
print(user)   # {}

Iterating Over Dictionaries

Dictionaries support several iteration patterns. The default behavior iterates over keys.

user = {"name": "Folau", "age": 30, "city": "Salt Lake City"}

# Iterate over keys (default)
for key in user:
    print(key)
# name
# age
# city

# Iterate over values
for value in user.values():
    print(value)
# Folau
# 30
# Salt Lake City

# Iterate over key-value pairs (most common)
for key, value in user.items():
    print(f"{key}: {value}")
# name: Folau
# age: 30
# city: Salt Lake City

# With enumerate (when you also need an index)
for index, (key, value) in enumerate(user.items()):
    print(f"{index}. {key} = {value}")
# 0. name = Folau
# 1. age = 30
# 2. city = Salt Lake City

Dictionary Comprehensions

Dictionary comprehensions let you build dictionaries in a single expression, similar to list comprehensions. They are concise, readable, and often more performant than building a dict with a loop.

Basic comprehension

# Square numbers
squares = {n: n ** 2 for n in range(1, 8)}
print(squares)
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49}

# Swap keys and values
original = {"a": 1, "b": 2, "c": 3}
flipped = {v: k for k, v in original.items()}
print(flipped)
# {1: 'a', 2: 'b', 3: 'c'}

With conditions

scores = {"Alice": 92, "Bob": 67, "Charlie": 85, "Diana": 45, "Eve": 78}

# Only passing scores (>= 70)
passing = {name: score for name, score in scores.items() if score >= 70}
print(passing)
# {'Alice': 92, 'Charlie': 85, 'Eve': 78}

# Categorize scores
grades = {
    name: ("A" if score >= 90 else "B" if score >= 80 else "C" if score >= 70 else "F")
    for name, score in scores.items()
}
print(grades)
# {'Alice': 'A', 'Bob': 'F', 'Charlie': 'B', 'Diana': 'F', 'Eve': 'C'}

Nested comprehension

# Multiplication table as a nested dict
table = {
    i: {j: i * j for j in range(1, 4)}
    for i in range(1, 4)
}
print(table)
# {1: {1: 1, 2: 2, 3: 3}, 2: {1: 2, 2: 4, 3: 6}, 3: {1: 3, 2: 6, 3: 9}}
print(table[2][3])  # 6

Nested Dictionaries

Dictionaries can contain other dictionaries as values, creating a tree-like structure. This is the natural way to represent JSON data, configuration files, and hierarchical records in Python.

# A nested structure representing company data (JSON-like)
company = {
    "name": "Tech Corp",
    "founded": 2015,
    "departments": {
        "engineering": {
            "head": "Folau",
            "team_size": 25,
            "technologies": ["Python", "Java", "AWS"]
        },
        "marketing": {
            "head": "Sarah",
            "team_size": 10,
            "budget": 500000
        }
    },
    "locations": [
        {"city": "Salt Lake City", "is_hq": True},
        {"city": "San Francisco", "is_hq": False}
    ]
}

# Accessing nested data
print(company["departments"]["engineering"]["head"])          # Folau
print(company["departments"]["engineering"]["technologies"])  # ['Python', 'Java', 'AWS']
print(company["locations"][0]["city"])                        # Salt Lake City

# Safe nested access with get()
budget = company.get("departments", {}).get("sales", {}).get("budget", 0)
print(budget)  # 0 (no KeyError even though 'sales' does not exist)

For deeply nested structures, chaining get() calls is a common defensive pattern. Each call returns an empty dict if the key is missing, so the next get() still works without raising an error.

DefaultDict, OrderedDict, and Counter

The collections module provides specialized dictionary subclasses that handle common patterns more elegantly than a plain dict.

defaultdict — auto-initialize missing keys

from collections import defaultdict

# With a regular dict, you must check if a key exists before appending
groups = {}
words = ["apple", "banana", "avocado", "blueberry", "cherry", "apricot"]
for word in words:
    first_letter = word[0]
    if first_letter not in groups:
        groups[first_letter] = []
    groups[first_letter].append(word)

# With defaultdict, the factory function handles initialization
groups = defaultdict(list)
for word in words:
    groups[word[0]].append(word)

print(dict(groups))
# {'a': ['apple', 'avocado', 'apricot'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}

# defaultdict with int (perfect for counting)
word_count = defaultdict(int)
for word in ["apple", "banana", "apple", "cherry", "banana", "apple"]:
    word_count[word] += 1

print(dict(word_count))
# {'apple': 3, 'banana': 2, 'cherry': 1}

OrderedDict — dictionary with guaranteed order

from collections import OrderedDict

# Since Python 3.7, regular dicts preserve insertion order.
# OrderedDict is still useful for two reasons:
# 1. It supports move_to_end() and popitem(last=False)
# 2. Order matters in equality comparison

od = OrderedDict()
od["first"] = 1
od["second"] = 2
od["third"] = 3

# Move an item to the end
od.move_to_end("first")
print(list(od.keys()))  # ['second', 'third', 'first']

# Move to the beginning
od.move_to_end("third", last=False)
print(list(od.keys()))  # ['third', 'second', 'first']

# Pop from the front (FIFO behavior)
od.popitem(last=False)  # Removes 'third'
print(list(od.keys()))  # ['second', 'first']

# Equality comparison considers order
dict1 = OrderedDict(a=1, b=2)
dict2 = OrderedDict(b=2, a=1)
print(dict1 == dict2)  # False (order differs)

# Regular dicts ignore order in comparison
print({"a": 1, "b": 2} == {"b": 2, "a": 1})  # True

Counter — count occurrences effortlessly

from collections import Counter

# Count elements in a list
fruits = ["apple", "banana", "apple", "cherry", "banana", "apple", "date"]
fruit_count = Counter(fruits)
print(fruit_count)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1, 'date': 1})

# Most common elements
print(fruit_count.most_common(2))
# [('apple', 3), ('banana', 2)]

# Count characters in a string
char_count = Counter("mississippi")
print(char_count)
# Counter({'s': 4, 'i': 4, 'p': 2, 'm': 1})

# Arithmetic with Counters
inventory = Counter(apples=5, oranges=3, bananas=2)
sold = Counter(apples=2, oranges=1)
remaining = inventory - sold
print(remaining)
# Counter({'apples': 3, 'oranges': 2, 'bananas': 2})

# Combine inventories
new_stock = Counter(apples=10, grapes=5)
total = remaining + new_stock
print(total)
# Counter({'apples': 13, 'grapes': 5, 'oranges': 2, 'bananas': 2})

Practical Dictionary Examples

Word frequency counter

def word_frequency(text):
    """
    Count the frequency of each word in a text.
    Returns a dictionary sorted by frequency (descending).
    """
    # Normalize: lowercase and split on whitespace
    words = text.lower().split()
    # Remove punctuation from each word
    cleaned = [word.strip(".,!?;:\"'()") for word in words]
    # Count using a dict comprehension on Counter
    from collections import Counter
    counts = Counter(cleaned)
    # Sort by frequency
    return dict(counts.most_common())

sample = """Python is great. Python is powerful.
Python is used by developers who love Python."""

result = word_frequency(sample)
for word, count in result.items():
    print(f"  {word}: {count}")
# python: 4
# is: 3
# great: 1
# powerful: 1
# used: 1
# by: 1
# developers: 1
# who: 1
# love: 1

Configuration manager

class ConfigManager:
    """
    A simple configuration manager that supports defaults,
    environment-specific overrides, and dot-notation-style access.
    """

    def __init__(self, defaults=None):
        self._config = defaults.copy() if defaults else {}

    def load_env(self, env_name, overrides):
        """Apply environment-specific overrides."""
        self._config["environment"] = env_name
        self._config.update(overrides)

    def get(self, key, default=None):
        """Retrieve a config value with an optional default."""
        keys = key.split(".")
        value = self._config
        for k in keys:
            if isinstance(value, dict):
                value = value.get(k)
            else:
                return default
            if value is None:
                return default
        return value

    def set(self, key, value):
        """Set a config value."""
        self._config[key] = value

    def to_dict(self):
        return self._config.copy()


# Usage
defaults = {
    "app_name": "MyApp",
    "debug": False,
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "myapp_db"
    },
    "cache_ttl": 300
}

config = ConfigManager(defaults)
config.load_env("production", {
    "debug": False,
    "database": {
        "host": "db.production.com",
        "port": 5432,
        "name": "myapp_prod"
    },
    "cache_ttl": 3600
})

print(config.get("app_name"))          # MyApp
print(config.get("database.host"))     # db.production.com
print(config.get("missing_key", 42))   # 42
print(config.get("environment"))       # production

Caching with memoization

import time

def memoize(func):
    """
    A simple memoization decorator using a dictionary cache.
    Caches results of expensive function calls.
    """
    cache = {}

    def wrapper(*args):
        if args in cache:
            print(f"  Cache hit for {args}")
            return cache[args]
        print(f"  Computing result for {args}")
        result = func(*args)
        cache[args] = result
        return result

    wrapper.cache = cache  # Expose cache for inspection
    return wrapper


@memoize
def expensive_computation(n):
    """Simulate an expensive operation."""
    time.sleep(0.1)  # Simulate delay
    return n ** 3 + n ** 2 + n + 1


# First call - computes and caches
result1 = expensive_computation(10)
print(f"Result: {result1}")  # Result: 1111

# Second call - returns from cache instantly
result2 = expensive_computation(10)
print(f"Result: {result2}")  # Result: 1111

# Different argument - computes and caches
result3 = expensive_computation(5)
print(f"Result: {result3}")  # Result: 156

# Inspect the cache
print(f"Cache contents: {expensive_computation.cache}")
# Cache contents: {(10,): 1111, (5,): 156}

For production code, Python provides functools.lru_cache which handles this pattern with additional features like a maximum cache size and thread safety.

Part 2: Sets

Introduction to Sets

A set is an unordered collection of unique, hashable elements. Sets are implemented as hash tables (like dictionary keys without values), which gives them O(1) average time for membership testing, insertion, and deletion. If you need to check whether something is “in” a collection, a set is almost always the right choice — it is dramatically faster than scanning a list.

Sets are ideal when you need to:

  • Eliminate duplicate entries from a collection
  • Perform mathematical set operations (union, intersection, difference)
  • Test membership efficiently
  • Find common or unique elements between collections

Creating Sets

Sets can be created using curly braces or the set() constructor.

# Literal syntax with curly braces
fruits = {"apple", "banana", "cherry"}
print(fruits)       # {'cherry', 'apple', 'banana'} (order may vary)
print(type(fruits)) # <class 'set'>

# Using the set() constructor
numbers = set([1, 2, 3, 4, 5])
print(numbers)  # {1, 2, 3, 4, 5}

# From a string (each character becomes an element)
letters = set("hello")
print(letters)  # {'h', 'e', 'l', 'o'} (duplicates removed)

# IMPORTANT: empty set must use set(), not {}
empty_set = set()     # Correct: empty set
empty_dict = {}       # This is an empty DICTIONARY, not a set!
print(type(empty_set))   # <class 'set'>
print(type(empty_dict))  # <class 'dict'>

Deduplication — removing duplicates from a list

# The simplest way to remove duplicates
numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
unique = list(set(numbers))
print(unique)  # [1, 2, 3, 4, 5, 6, 9] (order not preserved)

# To preserve original order (Python 3.7+)
def deduplicate(items):
    """Remove duplicates while preserving insertion order."""
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

print(deduplicate(numbers))  # [3, 1, 4, 5, 9, 2, 6]

# Or use dict.fromkeys() (preserves order, Python 3.7+)
print(list(dict.fromkeys(numbers)))  # [3, 1, 4, 5, 9, 2, 6]

Set comprehension

# Create a set using comprehension syntax
even_squares = {x ** 2 for x in range(1, 11) if x % 2 == 0}
print(even_squares)  # {4, 16, 36, 64, 100}

Set Operations

Sets support all the standard mathematical set operations. Each operation is available as both a method and an operator.

python_devs = {"Alice", "Bob", "Charlie", "Diana"}
java_devs = {"Bob", "Diana", "Eve", "Frank"}

# UNION - all elements from both sets
# Method: .union() | Operator: |
all_devs = python_devs.union(java_devs)
print(all_devs)
# {'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'}

all_devs = python_devs | java_devs  # Same result

# INTERSECTION - elements in both sets
# Method: .intersection() | Operator: &
both = python_devs.intersection(java_devs)
print(both)  # {'Bob', 'Diana'}

both = python_devs & java_devs  # Same result

# DIFFERENCE - elements in first set but not in second
# Method: .difference() | Operator: -
python_only = python_devs.difference(java_devs)
print(python_only)  # {'Alice', 'Charlie'}

python_only = python_devs - java_devs  # Same result

java_only = java_devs - python_devs
print(java_only)  # {'Eve', 'Frank'}

# SYMMETRIC DIFFERENCE - elements in either set but not both
# Method: .symmetric_difference() | Operator: ^
exclusive = python_devs.symmetric_difference(java_devs)
print(exclusive)  # {'Alice', 'Charlie', 'Eve', 'Frank'}

exclusive = python_devs ^ java_devs  # Same result

The operator forms (|, &, -, ^) require both operands to be sets. The method forms accept any iterable as the argument, which can be more flexible.

# Method accepts any iterable
my_set = {1, 2, 3}
result = my_set.union([4, 5, 6])        # Works with a list
print(result)  # {1, 2, 3, 4, 5, 6}

# Operator requires a set
# my_set | [4, 5, 6]  # TypeError: unsupported operand type(s)
result = my_set | set([4, 5, 6])         # Must convert to set first

Set Methods

Beyond set operations, sets provide methods for adding, removing, and testing relationships between sets.

skills = {"Python", "Java", "SQL"}

# add() - add a single element
skills.add("Docker")
print(skills)  # {'Python', 'Java', 'SQL', 'Docker'}

# Adding a duplicate has no effect
skills.add("Python")
print(skills)  # {'Python', 'Java', 'SQL', 'Docker'}

# remove() - remove an element (raises KeyError if missing)
skills.remove("Java")
print(skills)  # {'Python', 'SQL', 'Docker'}
# skills.remove("Go")  # KeyError: 'Go'

# discard() - remove an element (NO error if missing)
skills.discard("Go")      # No error
skills.discard("Docker")  # Removes Docker
print(skills)  # {'Python', 'SQL'}

# pop() - remove and return an arbitrary element
skills = {"Python", "Java", "SQL", "Docker"}
removed = skills.pop()
print(f"Removed: {removed}")  # Removed: (arbitrary element)

# clear() - remove all elements
skills.clear()
print(skills)  # set()

Subset and superset testing

backend_skills = {"Python", "Java", "SQL", "Docker", "AWS"}
my_skills = {"Python", "SQL"}

# issubset() - is every element of my_skills in backend_skills?
print(my_skills.issubset(backend_skills))    # True
print(my_skills <= backend_skills)            # True (operator form)

# issuperset() - does backend_skills contain all of my_skills?
print(backend_skills.issuperset(my_skills))  # True
print(backend_skills >= my_skills)            # True (operator form)

# Proper subset (subset but not equal)
print(my_skills < backend_skills)  # True
print(backend_skills < backend_skills)  # False (equal, not proper subset)

# isdisjoint() - do the sets share NO elements?
frontend = {"React", "CSS", "JavaScript"}
print(frontend.isdisjoint(backend_skills))  # True (no overlap)
print(my_skills.isdisjoint(backend_skills)) # False (overlap exists)

Frozen Sets

A frozenset is an immutable version of a set. Once created, you cannot add or remove elements. Because frozensets are immutable and hashable, they can be used as dictionary keys or as elements of another set — something regular sets cannot do.

# Create a frozenset
immutable_skills = frozenset(["Python", "Java", "SQL"])
print(immutable_skills)  # frozenset({'Python', 'Java', 'SQL'})

# All read operations work
print("Python" in immutable_skills)  # True
print(len(immutable_skills))          # 3

# Set operations return new frozensets
more_skills = frozenset(["Docker", "Python"])
combined = immutable_skills | more_skills
print(combined)  # frozenset({'Python', 'Java', 'SQL', 'Docker'})

# Mutation is not allowed
# immutable_skills.add("Go")     # AttributeError
# immutable_skills.remove("SQL") # AttributeError

# Use as dictionary keys (regular sets cannot do this)
permissions = {
    frozenset(["read"]): "viewer",
    frozenset(["read", "write"]): "editor",
    frozenset(["read", "write", "admin"]): "admin"
}

user_perms = frozenset(["read", "write"])
print(permissions[user_perms])  # editor

# Use as elements of another set
set_of_sets = {frozenset([1, 2]), frozenset([3, 4])}
print(set_of_sets)  # {frozenset({1, 2}), frozenset({3, 4})}

Use frozenset when you need a set that should never change after creation — for example, representing a fixed set of permissions, a cache key based on a combination of values, or a constant lookup table.

Practical Set Examples

Remove duplicates while tracking what was removed

def find_duplicates(items):
    """
    Find and return duplicate items from a list.
    Returns a tuple of (unique_items, duplicates).
    """
    seen = set()
    duplicates = set()
    for item in items:
        if item in seen:
            duplicates.add(item)
        else:
            seen.add(item)
    return list(seen), list(duplicates)

names = ["Alice", "Bob", "Charlie", "Alice", "Diana", "Bob", "Eve", "Alice"]
unique, dupes = find_duplicates(names)
print(f"Unique: {unique}")
print(f"Duplicates: {dupes}")
# Unique: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']
# Duplicates: ['Alice', 'Bob']

Find common elements across multiple collections

def common_elements(*collections):
    """
    Find elements common to all provided collections.
    Accepts any number of iterables.
    """
    if not collections:
        return set()
    result = set(collections[0])
    for collection in collections[1:]:
        result &= set(collection)
    return result

team_a_skills = ["Python", "Java", "SQL", "Docker"]
team_b_skills = ["Python", "Go", "SQL", "Kubernetes"]
team_c_skills = ["Python", "Rust", "SQL", "AWS"]

shared = common_elements(team_a_skills, team_b_skills, team_c_skills)
print(f"Skills all teams share: {shared}")
# Skills all teams share: {'Python', 'SQL'}

Membership testing performance

import time

# Build a large dataset
data_list = list(range(1_000_000))
data_set = set(data_list)

target = 999_999  # Worst case for a list (last element)

# List lookup - O(n)
start = time.perf_counter()
for _ in range(1000):
    _ = target in data_list
list_time = time.perf_counter() - start

# Set lookup - O(1)
start = time.perf_counter()
for _ in range(1000):
    _ = target in data_set
set_time = time.perf_counter() - start

print(f"List lookup (1000x): {list_time:.4f}s")
print(f"Set lookup  (1000x): {set_time:.6f}s")
print(f"Set is ~{list_time / set_time:.0f}x faster")
# Typical output:
# List lookup (1000x): 8.1234s
# Set lookup  (1000x): 0.000045s
# Set is ~180000x faster

This performance difference is exactly why you should convert a list to a set when you need to check membership repeatedly. The conversion itself is O(n), but each lookup after that is O(1).

Shared Topics

Common Pitfalls

1. Empty dict vs empty set — the {} trap

# This is a dict, NOT a set!
empty = {}
print(type(empty))  # <class 'dict'>

# To create an empty set, you must use set()
empty_set = set()
print(type(empty_set))  # <class 'set'>

2. Unhashable types as dictionary keys or set elements

# Lists and dicts are mutable, so they are NOT hashable
# my_dict = {[1, 2, 3]: "value"}   # TypeError: unhashable type: 'list'
# my_set = {[1, 2], [3, 4]}         # TypeError: unhashable type: 'list'

# Use tuples instead (they are immutable and hashable)
my_dict = {(1, 2, 3): "value"}     # Works
my_set = {(1, 2), (3, 4)}          # Works
print(my_dict[(1, 2, 3)])  # value

# But tuples containing mutable objects are also unhashable
# bad = {([1, 2], [3, 4]): "value"}  # TypeError: unhashable type: 'list'

3. Dictionary ordering assumptions

# Since Python 3.7, dicts preserve INSERTION order.
# But do not assume dicts are sorted by key or value.
d = {"banana": 2, "apple": 1, "cherry": 3}
print(list(d.keys()))  # ['banana', 'apple', 'cherry'] (insertion order)

# If you need sorted keys, sort explicitly
for key in sorted(d.keys()):
    print(f"{key}: {d[key]}")
# apple: 1
# banana: 2
# cherry: 3

4. Modifying a dict while iterating over it

scores = {"Alice": 90, "Bob": 45, "Charlie": 72, "Diana": 38}

# BAD: modifying during iteration causes RuntimeError
# for name, score in scores.items():
#     if score < 50:
#         del scores[name]  # RuntimeError: dictionary changed size during iteration

# GOOD: collect keys to remove, then delete
to_remove = [name for name, score in scores.items() if score < 50]
for name in to_remove:
    del scores[name]
print(scores)  # {'Alice': 90, 'Charlie': 72}

# Or use a dict comprehension to create a new dict
scores = {"Alice": 90, "Bob": 45, "Charlie": 72, "Diana": 38}
passing = {k: v for k, v in scores.items() if v >= 50}
print(passing)  # {'Alice': 90, 'Charlie': 72}

Best Practices

1. Use get() with defaults instead of bracket notation

When a missing key is a normal possibility (not an error), use get() to avoid try/except blocks or pre-checks with in.

# Instead of this:
if "email" in user:
    email = user["email"]
else:
    email = "not provided"

# Do this:
email = user.get("email", "not provided")

2. Use dict comprehensions over manual loops

# Instead of this:
result = {}
for key, value in data.items():
    if value > 0:
        result[key] = value * 2

# Do this:
result = {k: v * 2 for k, v in data.items() if v > 0}

3. Use sets for membership testing

If you check if x in collection inside a loop, convert collection to a set first. The speedup can be orders of magnitude on large datasets.

# Instead of searching a list:
valid_codes = ["US", "CA", "MX", "UK", "DE", "FR", "JP"]  # O(n) per lookup

# Use a set:
valid_codes = {"US", "CA", "MX", "UK", "DE", "FR", "JP"}  # O(1) per lookup

if country_code in valid_codes:
    process(country_code)

4. Use defaultdict or setdefault to avoid key-existence checks

from collections import defaultdict

# Instead of:
groups = {}
for item in items:
    key = item["category"]
    if key not in groups:
        groups[key] = []
    groups[key].append(item)

# Do this:
groups = defaultdict(list)
for item in items:
    groups[item["category"]].append(item)

5. Use the merge operator for combining dicts (Python 3.9+)

# Clean, readable dict merging
defaults = {"timeout": 30, "retries": 3, "verbose": False}
user_config = {"timeout": 60, "verbose": True}

final = defaults | user_config  # user_config wins on conflicts

6. Prefer frozenset when immutability is needed

If a set should not change after creation, use frozenset. This communicates intent, prevents accidental modification, and enables use as a dict key or set element.

Key Takeaways

  • Dictionaries store key-value pairs with O(1) average lookup, insertion, and deletion. They are the go-to structure for mappings, lookups, and structured data.
  • Create dicts with literals {}, dict(), comprehensions, or fromkeys(). Use whichever is most readable for your situation.
  • Always prefer get() with a default over bracket notation when a missing key is a normal case, not an error.
  • update() and the |= operator (Python 3.9+) merge dictionaries. The right-hand side wins on key conflicts.
  • defaultdict, Counter, and OrderedDict from the collections module handle specialized patterns more cleanly than a plain dict.
  • Dictionary comprehensions are concise, readable, and usually faster than manual loops.
  • Sets store unique, hashable elements with O(1) membership testing. Use them when duplicates are not allowed or when you need fast "is this in the collection?" checks.
  • Set operations (|, &, -, ^) correspond to union, intersection, difference, and symmetric difference. Both operator and method forms are available.
  • frozenset is an immutable set that can be used as a dictionary key or an element of another set.
  • Remember: {} creates an empty dict, not a set. Use set() for an empty set.
  • Never use mutable objects (lists, dicts, sets) as dictionary keys or set elements — use their immutable counterparts (tuples, frozensets) instead.
  • Convert lists to sets when you need repeated membership checks — the performance difference is dramatic.
  • Never modify a dictionary or set while iterating over it — collect changes first, then apply them.
March 11, 2021

Python – Lists & List Comprehensions

Introduction

Lists are the workhorse of Python data structures. If you write Python for any length of time, you will use lists more than almost anything else. They are ordered, mutable, and flexible enough to hold any mix of data types. Whether you are storing user records, processing CSV rows, building queues, or transforming datasets, lists are your go-to tool.

In this tutorial we will cover everything you need to work with lists confidently — from creation and manipulation all the way through list comprehensions, copying gotchas, and real-world patterns you will use on the job every day.


1. Creating Lists

There are several ways to create a list in Python. Pick the one that fits your situation.

List Literal

The most common approach — just use square brackets.

# empty list
empty = []

# list of integers
numbers = [1, 2, 3, 4, 5]

# mixed types — perfectly valid
mixed = ["Alice", 30, True, 3.14, None]

# nested list
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

print(numbers)  # [1, 2, 3, 4, 5]
print(mixed)    # ['Alice', 30, True, 3.14, None]

The list() Constructor

Converts any iterable into a list.

# from a string
chars = list("hello")
print(chars)  # ['h', 'e', 'l', 'l', 'o']

# from a tuple
from_tuple = list((10, 20, 30))
print(from_tuple)  # [10, 20, 30]

# from a set (order not guaranteed)
from_set = list({3, 1, 2})
print(from_set)  # [1, 2, 3] — order may vary

# from a dictionary (keys only)
from_dict = list({"name": "Alice", "age": 30})
print(from_dict)  # ['name', 'age']

Using range()

Generate sequences of numbers quickly.

# 0 through 9
nums = list(range(10))
print(nums)  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# 5 through 14
nums = list(range(5, 15))
print(nums)  # [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

# even numbers from 0 to 20
evens = list(range(0, 21, 2))
print(evens)  # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

# countdown
countdown = list(range(10, 0, -1))
print(countdown)  # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

2. Accessing Elements

Lists are zero-indexed. You can access elements from the front or the back, and you can slice out sub-lists with ease.

Indexing

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

# first element
print(fruits[0])   # apple

# third element
print(fruits[2])   # cherry

# last element
print(fruits[-1])  # elderberry

# second to last
print(fruits[-2])  # date

Slicing

Slicing uses the syntax list[start:stop:step]. The start index is inclusive, the stop index is exclusive.

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

# elements from index 2 to 5 (exclusive)
print(numbers[2:5])    # [2, 3, 4]

# first three elements
print(numbers[:3])     # [0, 1, 2]

# from index 7 to the end
print(numbers[7:])     # [7, 8, 9]

# every other element
print(numbers[::2])    # [0, 2, 4, 6, 8]

# reverse the list
print(numbers[::-1])   # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# last three elements
print(numbers[-3:])    # [7, 8, 9]

# copy the entire list via slice
copy = numbers[:]
print(copy)            # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

3. Modifying Lists

Lists are mutable — you can change them in place after creation. This is one of the key differences between lists and tuples.

Direct Assignment

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

# replace an element
colors[1] = "yellow"
print(colors)  # ['red', 'yellow', 'blue']

# replace a slice
colors[1:3] = ["orange", "purple", "pink"]
print(colors)  # ['red', 'orange', 'purple', 'pink']

append()

Adds a single element to the end of the list. This is O(1) amortized — very fast.

stack = []
stack.append("first")
stack.append("second")
stack.append("third")
print(stack)  # ['first', 'second', 'third']

insert()

Inserts an element at a specific index. Everything after that index shifts right.

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

# insert at the beginning
letters.insert(0, "z")
print(letters)  # ['z', 'a', 'b', 'c', 'd']

extend()

Appends every element from an iterable to the list. Unlike append(), it unpacks the iterable.

first = [1, 2, 3]
second = [4, 5, 6]

first.extend(second)
print(first)  # [1, 2, 3, 4, 5, 6]

# extend with any iterable
first.extend(range(7, 10))
print(first)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# compare: append vs extend
a = [1, 2]
a.append([3, 4])   # adds the list as a single element
print(a)           # [1, 2, [3, 4]]

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

4. Removing Elements

Python gives you several ways to remove elements, each suited to a different scenario.

remove()

Removes the first occurrence of a value. Raises ValueError if not found.

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

fruits.remove("banana")  # removes the FIRST 'banana'
print(fruits)  # ['apple', 'cherry', 'banana']

# safe removal
try:
    fruits.remove("mango")
except ValueError:
    print("mango not in list")

pop()

Removes and returns an element by index. Defaults to the last element if no index is given.

numbers = [10, 20, 30, 40, 50]

# pop the last element
last = numbers.pop()
print(last)     # 50
print(numbers)  # [10, 20, 30, 40]

# pop at a specific index
second = numbers.pop(1)
print(second)   # 20
print(numbers)  # [10, 30, 40]

del Statement

Removes by index or slice. Does not return the removed value.

letters = ["a", "b", "c", "d", "e"]

# delete a single element
del letters[2]
print(letters)  # ['a', 'b', 'd', 'e']

# delete a slice
del letters[1:3]
print(letters)  # ['a', 'e']

# delete the entire list
del letters
# print(letters)  # NameError: name 'letters' is not defined

clear()

Empties the list but keeps the list object itself.

items = [1, 2, 3, 4, 5]
items.clear()
print(items)  # []
print(type(items))  # <class 'list'>

5. List Operations

Python lists support a handful of operators that make common tasks concise.

# Concatenation with +
list_a = [1, 2, 3]
list_b = [4, 5, 6]
combined = list_a + list_b
print(combined)  # [1, 2, 3, 4, 5, 6]

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

pattern = [1, 2] * 3
print(pattern)  # [1, 2, 1, 2, 1, 2]

# Membership with in
fruits = ["apple", "banana", "cherry"]
print("banana" in fruits)     # True
print("mango" in fruits)      # False
print("mango" not in fruits)  # True

# Length with len()
print(len(fruits))  # 3

# Count occurrences
numbers = [1, 2, 2, 3, 3, 3]
print(numbers.count(3))  # 3

# Find index of first occurrence
print(numbers.index(2))  # 1

6. Sorting

Sorting is one of the most common operations. Python gives you two choices: sort in place or return a new sorted list.

sort() — In-Place

Modifies the original list and returns None.

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

# ascending (default)
numbers.sort()
print(numbers)  # [1, 1, 2, 3, 4, 5, 6, 9]

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

sorted() — Returns New List

Leaves the original untouched.

original = [3, 1, 4, 1, 5]
sorted_list = sorted(original)

print(original)     # [3, 1, 4, 1, 5] — unchanged
print(sorted_list)  # [1, 1, 3, 4, 5]

Custom Sorting with key

The key parameter accepts a function that extracts a comparison key from each element.

# sort strings by length
words = ["python", "is", "a", "powerful", "language"]
words.sort(key=len)
print(words)  # ['a', 'is', 'python', 'powerful', 'language']

# sort by absolute value
numbers = [-5, 3, -1, 8, -2]
sorted_nums = sorted(numbers, key=abs)
print(sorted_nums)  # [-1, -2, 3, -5, 8]

# sort dictionaries by a specific key
students = [
    {"name": "Alice", "grade": 88},
    {"name": "Bob", "grade": 95},
    {"name": "Charlie", "grade": 72},
]

# sort by grade descending
students.sort(key=lambda s: s["grade"], reverse=True)
for s in students:
    print(f"{s['name']}: {s['grade']}")
# Bob: 95
# Alice: 88
# Charlie: 72

# multi-key sort: by grade descending, then name ascending
students_v2 = [
    {"name": "Alice", "grade": 88},
    {"name": "Bob", "grade": 88},
    {"name": "Charlie", "grade": 95},
]
students_v2.sort(key=lambda s: (-s["grade"], s["name"]))
for s in students_v2:
    print(f"{s['name']}: {s['grade']}")
# Charlie: 95
# Alice: 88
# Bob: 88

7. List Comprehensions

List comprehensions are one of the most Pythonic features in the language. They let you create new lists by transforming and filtering existing iterables — all in a single, readable expression.

Basic Syntax

# syntax: [expression for item in iterable]

# squares of 0 through 9
squares = [x**2 for x in range(10)]
print(squares)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# uppercase words
words = ["hello", "world", "python"]
upper = [w.upper() for w in words]
print(upper)  # ['HELLO', 'WORLD', 'PYTHON']

# string formatting
names = ["alice", "bob", "charlie"]
formatted = [name.title() for name in names]
print(formatted)  # ['Alice', 'Bob', 'Charlie']

With Conditions (Filtering)

# syntax: [expression for item in iterable if condition]

# even numbers only
evens = [x for x in range(20) if x % 2 == 0]
print(evens)  # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# words longer than 4 characters
words = ["hi", "hello", "hey", "howdy", "greetings"]
long_words = [w for w in words if len(w) > 4]
print(long_words)  # ['hello', 'howdy', 'greetings']

# filter and transform
numbers = [1, -2, 3, -4, 5, -6]
positive_squares = [x**2 for x in numbers if x > 0]
print(positive_squares)  # [1, 9, 25]

If-Else in Comprehensions

When you need an else clause, the conditional goes before the for.

# syntax: [expr_if_true if condition else expr_if_false for item in iterable]

numbers = [1, -2, 3, -4, 5]
abs_values = [x if x >= 0 else -x for x in numbers]
print(abs_values)  # [1, 2, 3, 4, 5]

labels = ["even" if x % 2 == 0 else "odd" for x in range(6)]
print(labels)  # ['even', 'odd', 'even', 'odd', 'even', 'odd']

Nested Comprehensions

# flatten a 2D list
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
print(flat)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# create a multiplication table
table = [[i * j for j in range(1, 6)] for i in range(1, 6)]
for row in table:
    print(row)
# [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]

# all pairs from two lists
colors = ["red", "blue"]
sizes = ["S", "M", "L"]
combinations = [(c, s) for c in colors for s in sizes]
print(combinations)
# [('red', 'S'), ('red', 'M'), ('red', 'L'), ('blue', 'S'), ('blue', 'M'), ('blue', 'L')]

Comprehensions vs map() and filter()

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

# map approach
squares_map = list(map(lambda x: x**2, numbers))

# comprehension approach — generally preferred
squares_comp = [x**2 for x in numbers]

print(squares_map)   # [1, 4, 9, 16, 25]
print(squares_comp)  # [1, 4, 9, 16, 25]

# filter approach
evens_filter = list(filter(lambda x: x % 2 == 0, numbers))

# comprehension approach
evens_comp = [x for x in numbers if x % 2 == 0]

print(evens_filter)  # [2, 4]
print(evens_comp)    # [2, 4]

# comprehensions are almost always more readable
# use map/filter when passing an existing function
result = list(map(str, numbers))  # this is clean enough
print(result)  # ['1', '2', '3', '4', '5']

8. Nested Lists

Lists can contain other lists, which gives you an easy way to represent tables, grids, and matrices.

# 2D list (matrix)
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# access element at row 1, column 2
print(matrix[1][2])  # 6

# access entire row
print(matrix[0])  # [1, 2, 3]

# access a column (no built-in syntax — use comprehension)
col_1 = [row[1] for row in matrix]
print(col_1)  # [2, 5, 8]

# iterate over all elements
for i, row in enumerate(matrix):
    for j, val in enumerate(row):
        print(f"matrix[{i}][{j}] = {val}")

# create an m x n grid of zeros
rows, cols = 3, 4
grid = [[0] * cols for _ in range(rows)]
for row in grid:
    print(row)
# [0, 0, 0, 0]
# [0, 0, 0, 0]
# [0, 0, 0, 0]

# WARNING: do NOT create a grid this way
bad_grid = [[0] * cols] * rows  # all rows reference the SAME list!
bad_grid[0][0] = 99
print(bad_grid)  # [[99, 0, 0, 0], [99, 0, 0, 0], [99, 0, 0, 0]] — oops!

Flattening a Nested List

nested = [[1, 2], [3, 4], [5, 6]]

# comprehension
flat = [item for sublist in nested for item in sublist]
print(flat)  # [1, 2, 3, 4, 5, 6]

# itertools approach for deeply nested structures
import itertools
flat_iter = list(itertools.chain.from_iterable(nested))
print(flat_iter)  # [1, 2, 3, 4, 5, 6]

# recursive flattening for arbitrary depth
def flatten(lst):
    result = []
    for item in lst:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result

deeply_nested = [1, [2, [3, [4, [5]]]]]
print(flatten(deeply_nested))  # [1, 2, 3, 4, 5]

9. Copying Lists

This is where many Python developers get burned. Understanding the difference between assignment, shallow copy, and deep copy is essential.

The Assignment Trap

original = [1, 2, 3]
alias = original  # NOT a copy — both names point to the same list

alias.append(4)
print(original)  # [1, 2, 3, 4] — original is modified!
print(alias is original)  # True

Shallow Copy

Creates a new list, but inner objects are still shared references.

import copy

original = [1, 2, 3]

# three ways to shallow copy
copy1 = original.copy()
copy2 = original[:]
copy3 = list(original)
copy4 = copy.copy(original)

copy1.append(4)
print(original)  # [1, 2, 3] — original is safe
print(copy1)     # [1, 2, 3, 4]

# but with nested lists, shallow copy is not enough
nested = [[1, 2], [3, 4]]
shallow = nested.copy()

shallow[0][0] = 99  # modifies the inner list
print(nested)   # [[99, 2], [3, 4]] — original is affected!
print(shallow)  # [[99, 2], [3, 4]]

Deep Copy

Recursively copies everything. Use this when your list contains mutable objects.

import copy

nested = [[1, 2], [3, 4]]
deep = copy.deepcopy(nested)

deep[0][0] = 99
print(nested)  # [[1, 2], [3, 4]] — original is safe
print(deep)    # [[99, 2], [3, 4]]

10. Built-in Functions for Lists

Python ships with many built-in functions that work beautifully with lists.

numbers = [10, 20, 30, 40, 50]

print(len(numbers))   # 5
print(sum(numbers))   # 150
print(min(numbers))   # 10
print(max(numbers))   # 50

# any() — True if at least one element is truthy
print(any([0, 0, 1]))   # True
print(any([0, 0, 0]))   # False

# all() — True if every element is truthy
print(all([1, 2, 3]))   # True
print(all([1, 0, 3]))   # False

# practical: check if all scores pass
scores = [75, 82, 91, 68, 88]
all_passing = all(s >= 60 for s in scores)
print(all_passing)  # True

enumerate() — Index + Value

Stop writing for i in range(len(list)). Use enumerate instead.

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

# bad — do not do this
for i in range(len(fruits)):
    print(i, fruits[i])

# good — use enumerate
for i, fruit in enumerate(fruits):
    print(i, fruit)

# start counting from 1
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry

zip() — Iterate in Parallel

names = ["Alice", "Bob", "Charlie"]
scores = [88, 95, 72]
grades = ["B+", "A", "C"]

for name, score, grade in zip(names, scores, grades):
    print(f"{name}: {score} ({grade})")
# Alice: 88 (B+)
# Bob: 95 (A)
# Charlie: 72 (C)

# create a dictionary from two lists
name_score = dict(zip(names, scores))
print(name_score)  # {'Alice': 88, 'Bob': 95, 'Charlie': 72}

# unzip with zip(*...)
pairs = [(1, 'a'), (2, 'b'), (3, 'c')]
nums, letters = zip(*pairs)
print(nums)     # (1, 2, 3)
print(letters)  # ('a', 'b', 'c')

11. Stacks and Queues with Lists

Lists can serve double duty as stacks and (with the right tool) queues.

Stack (LIFO)

Use append() to push and pop() to pop. Both are O(1).

stack = []

# push
stack.append("page_1")
stack.append("page_2")
stack.append("page_3")
print(stack)  # ['page_1', 'page_2', 'page_3']

# pop
current = stack.pop()
print(current)  # page_3
print(stack)    # ['page_1', 'page_2']

# peek (look without removing)
if stack:
    print(stack[-1])  # page_2

# practical: bracket matching
def is_balanced(s):
    stack = []
    pairs = {')': '(', ']': '[', '}': '{'}
    for char in s:
        if char in '([{':
            stack.append(char)
        elif char in ')]}':
            if not stack or stack[-1] != pairs[char]:
                return False
            stack.pop()
    return len(stack) == 0

print(is_balanced("({[]})"))   # True
print(is_balanced("({[}])"))   # False
print(is_balanced("((())"))    # False

Queue (FIFO)

Do not use list.pop(0) for queues — it is O(n) because every element must shift. Use collections.deque instead.

from collections import deque

queue = deque()

# enqueue
queue.append("task_1")
queue.append("task_2")
queue.append("task_3")
print(queue)  # deque(['task_1', 'task_2', 'task_3'])

# dequeue
next_task = queue.popleft()  # O(1)
print(next_task)  # task_1
print(queue)      # deque(['task_2', 'task_3'])

# practical: process tasks
while queue:
    task = queue.popleft()
    print(f"Processing: {task}")
# Processing: task_2
# Processing: task_3

12. Practical Examples

Let us put it all together with four real-world examples.

Example 1: Todo List Manager

class TodoList:
    def __init__(self):
        self.tasks = []

    def add(self, task):
        self.tasks.append({"task": task, "done": False})
        print(f"Added: {task}")

    def complete(self, index):
        if 0 <= index < len(self.tasks):
            self.tasks[index]["done"] = True
            print(f"Completed: {self.tasks[index]['task']}")
        else:
            print("Invalid task index")

    def remove(self, index):
        if 0 <= index < len(self.tasks):
            removed = self.tasks.pop(index)
            print(f"Removed: {removed['task']}")
        else:
            print("Invalid task index")

    def show(self):
        if not self.tasks:
            print("No tasks!")
            return
        for i, t in enumerate(self.tasks):
            status = "done" if t["done"] else "pending"
            print(f"  {i}. [{status}] {t['task']}")

    def pending(self):
        return [t for t in self.tasks if not t["done"]]

    def completed(self):
        return [t for t in self.tasks if t["done"]]


todo = TodoList()
todo.add("Write Python tutorial")
todo.add("Review pull request")
todo.add("Deploy to production")
todo.complete(0)
todo.show()
# 0. [done] Write Python tutorial
# 1. [pending] Review pull request
# 2. [pending] Deploy to production

print(f"Pending: {len(todo.pending())}")
print(f"Completed: {len(todo.completed())}")

Example 2: Matrix Operations

def print_matrix(m, label=""):
    if label:
        print(f"{label}:")
    for row in m:
        print(f"  {row}")
    print()

def matrix_add(a, b):
    """Add two matrices element-wise."""
    return [
        [a[i][j] + b[i][j] for j in range(len(a[0]))]
        for i in range(len(a))
    ]

def matrix_transpose(m):
    """Transpose a matrix (swap rows and columns)."""
    return [[row[i] for row in m] for i in range(len(m[0]))]

def matrix_multiply(a, b):
    """Multiply two matrices."""
    rows_a, cols_a = len(a), len(a[0])
    rows_b, cols_b = len(b), len(b[0])
    assert cols_a == rows_b, "Incompatible dimensions"

    return [
        [sum(a[i][k] * b[k][j] for k in range(cols_a)) for j in range(cols_b)]
        for i in range(rows_a)
    ]

# usage
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]

print_matrix(matrix_add(A, B), "A + B")
# A + B:
#   [6, 8]
#   [10, 12]

print_matrix(matrix_transpose(A), "Transpose of A")
# Transpose of A:
#   [1, 3]
#   [2, 4]

print_matrix(matrix_multiply(A, B), "A x B")
# A x B:
#   [19, 22]
#   [43, 50]

Example 3: Data Filtering Pipeline

employees = [
    {"name": "Alice", "dept": "Engineering", "salary": 95000, "years": 5},
    {"name": "Bob", "dept": "Marketing", "salary": 65000, "years": 3},
    {"name": "Charlie", "dept": "Engineering", "salary": 110000, "years": 8},
    {"name": "Diana", "dept": "Engineering", "salary": 88000, "years": 2},
    {"name": "Eve", "dept": "Marketing", "salary": 72000, "years": 6},
    {"name": "Frank", "dept": "Sales", "salary": 58000, "years": 1},
]

# pipeline: filter -> transform -> sort

# step 1: engineers only
engineers = [e for e in employees if e["dept"] == "Engineering"]

# step 2: add a seniority level
for eng in engineers:
    eng["level"] = "Senior" if eng["years"] >= 5 else "Mid" if eng["years"] >= 3 else "Junior"

# step 3: sort by salary descending
engineers.sort(key=lambda e: e["salary"], reverse=True)

# step 4: format output
report = [
    f"{e['name']} ({e['level']}) - ${e['salary']:,}"
    for e in engineers
]

for line in report:
    print(line)
# Charlie (Senior) - $110,000
# Alice (Senior) - $95,000
# Diana (Junior) - $88,000

# summary stats
salaries = [e["salary"] for e in engineers]
print(f"\nAverage salary: ${sum(salaries) / len(salaries):,.0f}")
print(f"Total headcount: {len(engineers)}")

Example 4: Simple Inventory System

class Inventory:
    def __init__(self):
        self.items = []

    def add_item(self, name, quantity, price):
        # check if item already exists
        for item in self.items:
            if item["name"].lower() == name.lower():
                item["quantity"] += quantity
                print(f"Updated {name}: quantity is now {item['quantity']}")
                return
        self.items.append({"name": name, "quantity": quantity, "price": price})
        print(f"Added {name}: {quantity} units at ${price:.2f} each")

    def remove_item(self, name, quantity=1):
        for item in self.items:
            if item["name"].lower() == name.lower():
                if item["quantity"] >= quantity:
                    item["quantity"] -= quantity
                    if item["quantity"] == 0:
                        self.items.remove(item)
                        print(f"Removed {name} from inventory")
                    else:
                        print(f"Removed {quantity} {name}(s). Remaining: {item['quantity']}")
                else:
                    print(f"Not enough {name} in stock (have {item['quantity']})")
                return
        print(f"{name} not found in inventory")

    def search(self, keyword):
        return [item for item in self.items if keyword.lower() in item["name"].lower()]

    def total_value(self):
        return sum(item["quantity"] * item["price"] for item in self.items)

    def low_stock(self, threshold=5):
        return [item for item in self.items if item["quantity"] <= threshold]

    def report(self):
        if not self.items:
            print("Inventory is empty")
            return
        print(f"{'Item':<20} {'Qty':>5} {'Price':>8} {'Value':>10}")
        print("-" * 45)
        for item in sorted(self.items, key=lambda x: x["name"]):
            value = item["quantity"] * item["price"]
            print(f"{item['name']:<20} {item['quantity']:>5} ${item['price']:>7.2f} ${value:>9.2f}")
        print("-" * 45)
        print(f"{'Total':<26} {'':<8} ${self.total_value():>9.2f}")


inv = Inventory()
inv.add_item("Laptop", 10, 999.99)
inv.add_item("Mouse", 50, 29.99)
inv.add_item("Keyboard", 30, 79.99)
inv.add_item("Monitor", 3, 349.99)

inv.report()

print(f"\nLow stock items: {[i['name'] for i in inv.low_stock()]}")

13. Common Pitfalls

These are the mistakes that trip up developers at every level. Learn them once and you will save yourself hours of debugging.

Pitfall 1: Mutable Default Arguments

# BAD — the default list is shared across all calls
def add_item_bad(item, items=[]):
    items.append(item)
    return items

print(add_item_bad("a"))  # ['a']
print(add_item_bad("b"))  # ['a', 'b'] — not ['b']!
print(add_item_bad("c"))  # ['a', 'b', 'c'] — keeps growing

# GOOD — use None as default
def add_item_good(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item_good("a"))  # ['a']
print(add_item_good("b"))  # ['b'] — correct!

Pitfall 2: Modifying a List While Iterating

# BAD — skips elements
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)
print(numbers)  # [1, 3, 5] — might look right, but try with [2, 4, 6, 8]

# GOOD — iterate over a copy
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers[:]:
    if num % 2 == 0:
        numbers.remove(num)
print(numbers)  # [1, 3, 5]

# BEST — use a list comprehension
numbers = [1, 2, 3, 4, 5, 6]
numbers = [num for num in numbers if num % 2 != 0]
print(numbers)  # [1, 3, 5]

Pitfall 3: Shallow Copy Surprise

import copy

# shallow copy does NOT copy nested objects
original = [[1, 2], [3, 4]]
shallow = original.copy()
shallow[0][0] = 99
print(original)  # [[99, 2], [3, 4]] — original is affected!

# always use deepcopy for nested structures
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0][0] = 99
print(original)  # [[1, 2], [3, 4]] — safe

Pitfall 4: Using * to Create Nested Lists

# BAD — all rows share the same inner list
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

# GOOD — use a comprehension
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

14. Best Practices

Follow these habits and your list code will be clean, fast, and easy to maintain.

# 1. Prefer list comprehensions over manual loops
# bad
result = []
for x in range(10):
    if x % 2 == 0:
        result.append(x ** 2)

# good
result = [x ** 2 for x in range(10) if x % 2 == 0]


# 2. Use enumerate() instead of range(len(...))
# bad
for i in range(len(items)):
    print(i, items[i])

# good
for i, item in enumerate(items):
    print(i, item)


# 3. Use zip() to iterate over multiple lists
# bad
for i in range(len(names)):
    print(names[i], scores[i])

# good
for name, score in zip(names, scores):
    print(name, score)


# 4. Use unpacking
first, *middle, last = [1, 2, 3, 4, 5]
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5


# 5. Prefer 'in' over index checking
# bad
found = False
for item in items:
    if item == target:
        found = True
        break

# good
found = target in items


# 6. Use list.sort() when you do not need the original
# sort() is slightly faster than sorted() because it does not create a new list
data = [3, 1, 4, 1, 5]
data.sort()  # in-place, no new list created


# 7. Use collections.deque for queue operations
from collections import deque
queue = deque()  # O(1) append and popleft

15. Key Takeaways

  • Lists are ordered, mutable, and allow duplicates. They are your default choice when you need a collection of items.
  • Indexing starts at 0. Use negative indices to count from the end. Slicing is powerful — learn [start:stop:step] and you will use it everywhere.
  • append() and pop() are O(1). insert(0, ...) and pop(0) are O(n). Use deque when you need fast operations at both ends.
  • List comprehensions are Pythonic. They are faster and more readable than manual for loops with append. Use them liberally.
  • Know the difference between shallow and deep copy. Assignment creates an alias, not a copy. Use copy.deepcopy() for nested structures.
  • Never use a mutable default argument. Use None as the default and create the list inside the function.
  • Never modify a list while iterating over it. Use a comprehension or iterate over a copy.
  • Use enumerate() and zip() instead of index-based loops. Your code will be shorter and less error-prone.
  • sort() mutates, sorted() returns a new list. Use the key parameter with lambdas for custom sort orders.
  • Lists are versatile enough to serve as stacks. For queues, reach for collections.deque instead.

Lists are fundamental to Python programming. Master them and you have a solid foundation for everything else — from data processing to algorithms to web development. Practice the examples in this tutorial, experiment with variations, and you will have list fluency in no time.

March 10, 2021

Python – Function

If you have been writing Python for any length of time, you have already been using functions — print(), len(), range(). Functions are the fundamental building blocks of reusable, maintainable code. They allow you to encapsulate a piece of logic, give it a name, and call it whenever you need it, from anywhere in your program. This follows the DRY principle (Don’t Repeat Yourself): write the logic once, and reuse it as many times as necessary. Without functions, you would be copying and pasting the same blocks of code throughout your project, creating a maintenance nightmare where a single bug fix requires changes in dozens of places.

In this tutorial, we will explore Python functions thoroughly — from basic definitions to advanced patterns like *args, **kwargs, type hints, and first-class function behavior. By the end, you will write functions the way a seasoned Python developer does.

Defining a Function

You define a function in Python using the def keyword, followed by the function name, parentheses for parameters, and a colon. The indented block beneath is the function body.

def function_name(parameters):
    """Optional docstring describing the function."""
    # function body
    statements

To call a function, use its name followed by parentheses, passing in any required arguments.

def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Folau")  # Output: Hello, Folau!

Function names should be lowercase, with words separated by underscores (snake_case). Choose descriptive names that clearly communicate what the function does: calculate_tax() is far better than ct().

Parameters vs Arguments

These two terms are often used interchangeably, but they have distinct meanings. A parameter is the variable listed in the function definition. An argument is the actual value you pass when calling the function.

def greet(name):      # 'name' is a parameter
    print(f"Hi, {name}!")

greet("Folau")        # "Folau" is an argument

By default, a function must be called with the exact number of arguments it expects. If your function defines two parameters, you must supply exactly two arguments — no more, no less — unless you use default parameters or variable-length arguments (covered below).

Return Values

A function can send a result back to the caller using the return statement. Once return executes, the function exits immediately.

def add(a, b):
    return a + b

result = add(3, 5)
print(result)  # 8

Returning multiple values is straightforward in Python — just separate them with commas. Under the hood, Python packs them into a tuple.

def get_user_info():
    name = "Folau"
    age = 30
    email = "folau@example.com"
    return name, age, email

# Unpack the returned tuple
user_name, user_age, user_email = get_user_info()
print(f"{user_name}, {user_age}, {user_email}")
# Output: Folau, 30, folau@example.com

Default Parameters

You can assign default values to parameters. If the caller does not provide an argument for that parameter, the default is used. Parameters with defaults must come after parameters without defaults.

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Folau")              # Hello, Folau!
greet("Folau", "Good morning")  # Good morning, Folau!

This is useful for providing sensible defaults while still allowing customization when needed.

Keyword Arguments

When calling a function, you can pass arguments using the key=value syntax. This makes calls more readable and allows you to pass arguments in any order.

def create_profile(name, age, city):
    print(f"{name}, age {age}, from {city}")

# Using keyword arguments - order doesn't matter
create_profile(city="Salt Lake City", name="Folau", age=30)
# Output: Folau, age 30, from Salt Lake City

Arbitrary Arguments with *args

When you don’t know in advance how many arguments will be passed to your function, prefix a parameter with *. The function will receive them as a tuple, and you can iterate over them or index into them.

def flexible_sum(*numbers):
    """Return the sum of any number of numeric arguments."""
    total = 0
    for num in numbers:
        total += num
    return total

print(flexible_sum(1, 2, 3))          # 6
print(flexible_sum(10, 20, 30, 40))   # 100
print(flexible_sum(5))                # 5

You can combine *args with regular parameters. The regular parameters come first.

def print_profile(role, *skills):
    print(f"Role: {role}")
    print(f"Skills: {', '.join(skills)}")

print_profile("Developer", "Python", "Java", "SQL")
# Role: Developer
# Skills: Python, Java, SQL

Arbitrary Keyword Arguments with **kwargs

If you don’t know how many keyword arguments will be passed, prefix a parameter with **. The function will receive them as a dictionary.

def build_config(**settings):
    """Build a configuration dictionary from keyword arguments."""
    config = {}
    for key, value in settings.items():
        config[key] = value
        print(f"  {key} = {value}")
    return config

config = build_config(
    host="localhost",
    port=5432,
    database="mydb",
    debug=True
)
# Output:
#   host = localhost
#   port = 5432
#   database = mydb
#   debug = True

You can combine all parameter types in a single function. The order must be: regular parameters, *args, keyword-only parameters, **kwargs.

def make_request(url, *headers, timeout=30, **params):
    print(f"URL: {url}")
    print(f"Headers: {headers}")
    print(f"Timeout: {timeout}")
    print(f"Params: {params}")

make_request("https://api.example.com", "Auth: Bearer token", timeout=10, page=1, limit=20)

Docstrings

A docstring is a string literal placed as the first statement in a function body. It documents what the function does, its parameters, and its return value. You can access it at runtime via the __doc__ attribute or the help() function.

def calculate_area(length, width):
    """
    Calculate the area of a rectangle.

    Args:
        length (float): The length of the rectangle.
        width (float): The width of the rectangle.

    Returns:
        float: The area of the rectangle.
    """
    return length * width

# Access the docstring
print(calculate_area.__doc__)
help(calculate_area)

Good docstrings save hours of reading code. They are especially valuable on teams, where someone else (or future-you) needs to understand what a function does without reading through the implementation line by line.

Variable Scope: Local vs Global

Variables defined inside a function are local — they exist only within that function. Variables defined outside all functions are global — they are accessible throughout the module. Understanding scope prevents subtle bugs.

counter = 0  # global variable

def increment():
    counter = 1  # this creates a LOCAL variable, does NOT modify the global
    print(f"Inside function: counter = {counter}")

increment()
print(f"Outside function: counter = {counter}")
# Inside function: counter = 1
# Outside function: counter = 0

To modify a global variable from within a function, use the global keyword — but use it sparingly. Global mutable state makes programs harder to reason about and debug.

counter = 0

def increment():
    global counter
    counter += 1

increment()
increment()
print(counter)  # 2

The LEGB Rule: Python resolves variable names by searching four scopes in order: Local (inside the current function), Enclosing (inside any enclosing functions, for nested functions), Global (module-level), and Built-in (Python’s built-in names like print, len). The first match wins.

First-Class Functions

In Python, functions are first-class objects. This means you can assign them to variables, store them in data structures, pass them as arguments to other functions, and return them from functions — just like any other value.

# Assign a function to a variable
def square(x):
    return x * x

operation = square
print(operation(5))  # 25

# Pass a function as an argument
def apply_operation(func, value):
    return func(value)

print(apply_operation(square, 4))  # 16

# Return a function from a function
def make_multiplier(factor):
    def multiplier(x):
        return x * factor
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10))  # 20
print(triple(10))  # 30

This capability is what makes patterns like decorators, callbacks, and higher-order functions possible in Python.

Lambda Functions

A lambda is a small anonymous function defined with the lambda keyword. It can take any number of arguments but can only contain a single expression. Lambdas are most useful when you need a short, throwaway function — typically as an argument to higher-order functions like map(), filter(), or sorted().

# Simple lambda examples
double = lambda x: x * 2
multiply = lambda a, b: a * b

print(double(5))       # 10
print(multiply(3, 4))  # 12

Where lambdas really shine is inline usage with built-in functions.

# Filter even numbers
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8]

# Sort a list of tuples by the second element
pairs = [(1, "banana"), (3, "apple"), (2, "cherry")]
sorted_pairs = sorted(pairs, key=lambda pair: pair[1])
print(sorted_pairs)  # [(3, 'apple'), (1, 'banana'), (2, 'cherry')]

For anything more than a simple expression, use a regular def function instead. Lambdas should not contain complex logic — readability always comes first.

Recursive Functions

A recursive function is a function that calls itself. Every recursive function needs a base case (the condition that stops the recursion) to avoid infinite loops. Recursion is a natural fit for problems that can be broken down into smaller, identical subproblems.

def factorial(n):
    """
    Calculate the factorial of a non-negative integer.
    factorial(5) = 5 * 4 * 3 * 2 * 1 = 120
    """
    if n <= 1:       # base case
        return 1
    return n * factorial(n - 1)  # recursive case

print(factorial(5))   # 120
print(factorial(0))   # 1
print(factorial(10))  # 3628800

Be mindful that Python has a default recursion limit of 1000 calls. For deeply recursive problems, consider iterative solutions or increase the limit with sys.setrecursionlimit().

Type Hints (Python 3.5+)

Type hints let you annotate function parameters and return types. They do not enforce types at runtime — Python remains dynamically typed — but they serve as documentation and enable tools like mypy and IDE auto-completion to catch bugs before you run the code.

def calculate_circle_area(radius: float) -> float:
    """Calculate the area of a circle given its radius."""
    import math
    return math.pi * radius ** 2

def format_name(first: str, last: str, uppercase: bool = False) -> str:
    """Format a full name, optionally in uppercase."""
    full_name = f"{first} {last}"
    return full_name.upper() if uppercase else full_name

print(calculate_circle_area(5.0))               # 78.53981633974483
print(format_name("Folau", "Kaveinga"))          # Folau Kaveinga
print(format_name("Folau", "Kaveinga", True))    # FOLAU KAVEINGA

For more complex types, use the typing module.

from typing import List, Dict, Optional, Tuple

def find_max(numbers: List[int]) -> Optional[int]:
    """Return the maximum value, or None if the list is empty."""
    if not numbers:
        return None
    return max(numbers)

def get_user(user_id: int) -> Dict[str, str]:
    """Fetch a user record by ID."""
    return {"id": str(user_id), "name": "Folau"}

The pass Statement

Function definitions cannot be empty. If you need a placeholder function (for example, during early development or when defining an interface), use pass to avoid a syntax error.

def not_implemented_yet():
    pass  # TODO: implement this later

Practical Examples

Calculate area of different shapes

import math

def calculate_area(shape: str, **dimensions) -> float:
    """
    Calculate the area of a shape.

    Supported shapes: rectangle, circle, triangle.
    Pass dimensions as keyword arguments.
    """
    shape = shape.lower()

    if shape == "rectangle":
        return dimensions["length"] * dimensions["width"]
    elif shape == "circle":
        return math.pi * dimensions["radius"] ** 2
    elif shape == "triangle":
        return 0.5 * dimensions["base"] * dimensions["height"]
    else:
        raise ValueError(f"Unsupported shape: {shape}")

print(calculate_area("rectangle", length=10, width=5))  # 50
print(calculate_area("circle", radius=7))                # 153.938...
print(calculate_area("triangle", base=6, height=4))      # 12.0

Validate email format

import re

def is_valid_email(email: str) -> bool:
    """
    Validate an email address against a basic pattern.
    Returns True if the email format is valid, False otherwise.
    """
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

print(is_valid_email("folau@example.com"))     # True
print(is_valid_email("invalid-email"))          # False
print(is_valid_email("user@domain.co.uk"))      # True
print(is_valid_email("@missing-local.com"))     # False

Greeting with optional name (default parameters)

def greet(name: str = "World", greeting: str = "Hello", punctuation: str = "!") -> str:
    """Build a customizable greeting string."""
    return f"{greeting}, {name}{punctuation}"

print(greet())                                 # Hello, World!
print(greet("Folau"))                          # Hello, Folau!
print(greet("Folau", "Good morning"))          # Good morning, Folau!
print(greet("Folau", "Hey", "!!!"))            # Hey, Folau!!!

Using **kwargs for building configuration

def build_db_config(db_type: str = "postgresql", **overrides) -> dict:
    """
    Build a database configuration with sensible defaults.
    Any keyword argument overrides the default.
    """
    defaults = {
        "host": "localhost",
        "port": 5432,
        "database": "myapp",
        "user": "admin",
        "password": "",
        "pool_size": 5,
    }
    defaults.update(overrides)
    defaults["db_type"] = db_type
    return defaults

# Use defaults
config = build_db_config()
print(config)
# {'host': 'localhost', 'port': 5432, 'database': 'myapp', ...}

# Override specific settings
prod_config = build_db_config(
    db_type="postgresql",
    host="db.production.com",
    password="s3cur3p@ss",
    pool_size=20
)
print(prod_config["host"])       # db.production.com
print(prod_config["pool_size"])  # 20

Modules

A module is simply a .py file containing Python definitions and statements. For example, a file named user.py is a module named user. Modules help you organize code into logical, reusable units. Instead of copying function definitions across files, you import them.

import user

user.say_hello()

You can rename a module on import with as, or import specific items directly.

# Import with alias
import user as u
u.say_hello()

# Import a specific function
from user import say_hello
say_hello()

# Import everything (use sparingly - pollutes namespace)
from user import *

Python has an extensive standard library. You can browse the full list of Python standard modules to see what is available out of the box.

Common Pitfalls

1. Mutable default arguments (the classic Python gotcha)

Never use a mutable object (like a list or dictionary) as a default parameter value. The default is created once when the function is defined, not each time the function is called. This leads to shared state across calls.

# BAD - the same list is shared across all calls
def add_item_bad(item, items=[]):
    items.append(item)
    return items

print(add_item_bad("a"))  # ['a']
print(add_item_bad("b"))  # ['a', 'b']  -- unexpected!

# GOOD - use None and create a new list each call
def add_item_good(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item_good("a"))  # ['a']
print(add_item_good("b"))  # ['b']  -- correct

2. Forgetting to return (returns None)

If a function does not explicitly return a value, it returns None. This is a common source of bugs, especially when you intend the function to produce a result.

def calculate_total(prices):
    total = sum(prices)
    # Oops - forgot to return total!

result = calculate_total([10, 20, 30])
print(result)  # None  -- not what we wanted

# Fix: add return statement
def calculate_total(prices):
    total = sum(prices)
    return total

3. Variable shadowing

Creating a local variable with the same name as a global variable (or a built-in) hides the outer variable. This can cause confusing bugs.

values = [1, 2, 3]

def process():
    # This creates a NEW local variable named 'values', shadowing the global
    values = [10, 20, 30]
    print(f"Inside: {values}")   # [10, 20, 30]

process()
print(f"Outside: {values}")     # [1, 2, 3]  -- global is unchanged

# Even worse: shadowing built-ins
list = [1, 2, 3]  # Don't do this! Shadows the built-in list() function
# list("abc")  # TypeError: 'list' object is not callable

Best Practices

1. Keep functions small and focused (Single Responsibility): Each function should do one thing and do it well. If a function is doing multiple unrelated tasks, break it up. A good rule of thumb: if you cannot describe what the function does in a single sentence without using "and," it is doing too much.

2. Use descriptive names: Function names should clearly communicate intent. Prefer calculate_monthly_payment() over calc(), and is_valid_email() over check(). Your code should read like well-written prose.

3. Add docstrings: Every public function should have a docstring explaining what it does, what parameters it accepts, and what it returns. Follow a consistent style like Google, NumPy, or Sphinx format across your project.

4. Use type hints for clarity: Type hints make your function signatures self-documenting. They help your IDE provide better auto-completion and enable static analysis tools like mypy to catch type errors before runtime.

5. Avoid global state: Pass data into functions through parameters and get results through return values. Functions that rely on or modify global state are harder to test, debug, and reuse.

6. Handle edge cases: Think about what happens with empty inputs, negative numbers, None values, or unexpected types. Raise clear exceptions with informative messages when inputs are invalid.

Key Takeaways

  • Functions are defined with def and are the primary mechanism for code reuse in Python.
  • Parameters are in the definition; arguments are the values you pass when calling.
  • Use default parameters to make functions flexible without breaking existing calls.
  • *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dictionary.
  • Always add docstrings — they are the first thing another developer reads.
  • Understand scope (LEGB rule) to avoid variable shadowing and unintended side effects.
  • Python functions are first-class objects — you can pass them around like any other value.
  • Use type hints to make your code self-documenting and tooling-friendly.
  • Watch out for the mutable default argument pitfall — use None as the default instead.
  • Lambda functions are best for short, one-off expressions passed to higher-order functions.
  • Keep functions small, focused, and well-named — your future self will thank you.

Source code on Github

March 8, 2021

Python – Iteration(for/while loops)

Introduction

Iteration is one of the foundational concepts in programming. At its core, iteration means executing a block of code repeatedly — either a fixed number of times or until a condition is met. Without loops, you would have to write the same logic over and over for every element in a dataset, which is neither practical nor maintainable.

In Python, you will reach for a loop whenever you need to:

  • Process every item in a collection (list, dictionary, file lines, database rows)
  • Repeat an action until a condition changes (waiting for user input, polling a service)
  • Accumulate a result by examining elements one at a time (sums, searches, transformations)
  • Generate structured output like tables, reports, or formatted strings

Python provides two loop constructs: the for loop and the while loop. Alongside these, Python offers powerful built-in functions like enumerate(), zip(), and range() that make iteration concise and expressive. Let’s walk through each of these in depth.


For Loops

A for loop iterates over a sequence — any iterable object such as a list, tuple, string, dictionary, set, or range. The loop runs once for each element in the sequence, binding the current element to a variable you define.

Syntax

for variable in iterable:
    # do something with variable

The body of the loop is defined by indentation. Python does not use braces or keywords to delimit blocks — whitespace matters.

Iterating Over a List

This is the most common use of a for loop. Each element in the list is visited exactly once, in order.

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

for fruit in fruits:
    print(fruit)

Output:

apple
banana
cherry
mango

Iterating Over a String

Strings are sequences of characters, so a for loop walks through each character one at a time.

message = "Python"

for char in message:
    print(f"Letter: {char}")

Output:

Letter: P
Letter: y
Letter: t
Letter: h
Letter: o
Letter: n

Iterating with range()

The range() function generates a sequence of integers. It is the go-to tool when you need to loop a specific number of times or need numeric indices.

# range(stop) — 0 to stop-1
for i in range(5):
    print(i)  # 0, 1, 2, 3, 4

# range(start, stop) — start to stop-1
for i in range(2, 7):
    print(i)  # 2, 3, 4, 5, 6

# range(start, stop, step) — with a custom step
for i in range(0, 20, 3):
    print(i)  # 0, 3, 6, 9, 12, 15, 18

Notice that range() is exclusive of the stop value. This is by design and aligns with zero-based indexing. range(5) gives you exactly 5 elements: 0 through 4.

Iterating Over a Dictionary

When you iterate over a dictionary directly, you get its keys. To access values or both keys and values, use the .values() or .items() methods.

profile = {"name": "Folau", "age": 30, "role": "Software Engineer"}

# Iterating over keys (default behavior)
for key in profile:
    print(key)

# Iterating over values
for value in profile.values():
    print(value)

# Iterating over key-value pairs — most useful in practice
for key, value in profile.items():
    print(f"{key}: {value}")

Output of key-value iteration:

name: Folau
age: 30
role: Software Engineer

Iterating Over a Tuple and a Set

Tuples and sets are both iterable. The key difference is that sets are unordered, so the iteration order is not guaranteed.

# Tuple
coordinates = (10, 20, 30)
for coord in coordinates:
    print(coord)

# Set — order may vary between runs
languages = {"Python", "Java", "Go", "Rust"}
for lang in languages:
    print(lang)

While Loops

A while loop repeats a block of code as long as a condition evaluates to True. Use a while loop when you don’t know in advance how many iterations you need — the loop continues until the condition changes.

Syntax

while condition:
    # loop body

Example — counting up

count = 0
while count < 5:
    print(f"Count is {count}")
    count += 1

Output:

Count is 0
Count is 1
Count is 2
Count is 3
Count is 4

The critical thing with while loops is that you must ensure the condition eventually becomes False. If it doesn't, you have an infinite loop, and your program will hang.

Processing User Input Until Quit

A classic use case for while loops is reading user input until a sentinel value is entered.

while True:
    command = input("Enter a command (type 'quit' to exit): ")
    if command.lower() == "quit":
        print("Goodbye!")
        break
    print(f"You entered: {command}")

This pattern — while True with a break — is a clean way to handle input loops. The condition is always true, and the exit logic is handled explicitly inside the loop body.


break and continue Statements

These two statements give you fine-grained control over loop execution.

break — Exit the Loop Early

The break statement immediately terminates the innermost loop and resumes execution at the next statement after the loop.

numbers = [10, 25, 33, 47, 52, 68]

# Find the first number greater than 40
for num in numbers:
    if num > 40:
        print(f"Found it: {num}")
        break

Output:

Found it: 47

continue — Skip to the Next Iteration

The continue statement skips the rest of the current iteration and moves to the next one.

# Print only even numbers
for i in range(10):
    if i % 2 != 0:
        continue
    print(i)

Output:

0
2
4
6
8

Use continue when you want to skip certain elements without restructuring your loop with deeply nested if blocks. It keeps the code flat and readable.


The else Clause on Loops

This is a Python-specific feature that surprises many developers coming from other languages. Both for and while loops can have an else block. The else block executes only if the loop completes normally — that is, without hitting a break.

For loop with else

teams = ["Lakers", "Jazz", "Suns"]

for team in teams:
    print(team)
else:
    print("All teams processed successfully.")

Output:

Lakers
Jazz
Suns
All teams processed successfully.

Practical use — searching for an item

The else clause really shines when combined with break. Think of the else as "no break occurred":

target = "Celtics"
teams = ["Lakers", "Jazz", "Suns", "Warriors"]

for team in teams:
    if team == target:
        print(f"Found {target}!")
        break
else:
    print(f"{target} was not found in the list.")

Output:

Celtics was not found in the list.

If the break had been triggered, the else block would be skipped entirely. This eliminates the need for a separate "found" flag variable.

While loop with else

count = 0
while count < 5:
    print(count)
    count += 1
else:
    print("Loop completed without break.")

Nested Loops

You can place one loop inside another. The inner loop runs to completion for each iteration of the outer loop. Nested loops are common when working with multi-dimensional data or generating combinations.

Building a Multiplication Table

for i in range(1, 6):
    for j in range(1, 6):
        print(f"{i * j:4}", end="")
    print()  # new line after each 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

Nested loop with break

Keep in mind that break only exits the innermost loop. If you need to break out of multiple levels, you'll need a flag variable or refactor the logic into a function with a return.

# Find a specific cell in a matrix
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

target = 5
found = False

for row_idx, row in enumerate(matrix):
    for col_idx, value in enumerate(row):
        if value == target:
            print(f"Found {target} at row {row_idx}, col {col_idx}")
            found = True
            break
    if found:
        break

enumerate() — Index and Value Together

When you need both the index and the value during iteration, use enumerate(). This is far cleaner than manually tracking an index counter.

languages = ["Python", "Java", "Go", "Rust"]

# Without enumerate — avoid this pattern
index = 0
for lang in languages:
    print(f"{index}: {lang}")
    index += 1

# With enumerate — preferred approach
for index, lang in enumerate(languages):
    print(f"{index}: {lang}")

# You can also set a custom start index
for index, lang in enumerate(languages, start=1):
    print(f"{index}. {lang}")

Output (with start=1):

1. Python
2. Java
3. Go
4. Rust

Use enumerate() whenever you catch yourself creating a manual counter variable. It is more Pythonic and eliminates an entire category of off-by-one bugs.


zip() — Parallel Iteration

The zip() function lets you iterate over two or more sequences simultaneously. It pairs up elements by position and stops when the shortest sequence is exhausted.

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

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

Output:

Alice scored 85
Bob scored 92
Charlie scored 78

Zipping three sequences

names = ["Alice", "Bob", "Charlie"]
scores = [85, 92, 78]
grades = ["B", "A", "C+"]

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

If the sequences have different lengths and you want to iterate to the longest one, use itertools.zip_longest():

from itertools import zip_longest

names = ["Alice", "Bob", "Charlie"]
scores = [85, 92]

for name, score in zip_longest(names, scores, fillvalue="N/A"):
    print(f"{name}: {score}")

Output:

Alice: 85
Bob: 92
Charlie: N/A

Reverse Iteration with reversed()

To traverse a sequence in reverse order without modifying the original, use the built-in reversed() function.

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

for num in reversed(numbers):
    print(num)

Output:

5
4
3
2
1

You can also reverse a range:

for i in range(10, 0, -1):
    print(i)  # 10, 9, 8, ..., 1

Loop Comprehensions (Brief Introduction)

Python offers a concise syntax for creating new lists (and other collections) from loops. These are called list comprehensions and they can replace simple for loops that build a list.

# Traditional for loop approach
squares = []
for x in range(1, 6):
    squares.append(x ** 2)
print(squares)  # [1, 4, 9, 16, 25]

# List comprehension — same result, one line
squares = [x ** 2 for x in range(1, 6)]
print(squares)  # [1, 4, 9, 16, 25]

# With a condition — only even squares
even_squares = [x ** 2 for x in range(1, 11) if x % 2 == 0]
print(even_squares)  # [4, 16, 36, 64, 100]

Comprehensions also work with dictionaries and sets:

# Dictionary comprehension
word_lengths = {word: len(word) for word in ["Python", "Java", "Go"]}
print(word_lengths)  # {'Python': 6, 'Java': 4, 'Go': 2}

# Set comprehension
unique_lengths = {len(word) for word in ["Python", "Java", "Go", "Rust"]}
print(unique_lengths)  # {2, 4, 6}

Use comprehensions for simple transformations and filtering. If the logic gets complex with multiple conditions or side effects, a regular for loop is more readable.


Practical Examples

Summing Numbers in a List

expenses = [45.50, 120.00, 33.75, 89.99, 15.25]

total = 0
for expense in expenses:
    total += expense

print(f"Total expenses: ${total:.2f}")
# Output: Total expenses: $304.49

# Pythonic alternative using built-in sum()
total = sum(expenses)
print(f"Total expenses: ${total:.2f}")

Finding an Item in a Collection

employees = [
    {"name": "Alice", "department": "Engineering"},
    {"name": "Bob", "department": "Marketing"},
    {"name": "Charlie", "department": "Engineering"},
    {"name": "Diana", "department": "Sales"}
]

target_name = "Charlie"

for employee in employees:
    if employee["name"] == target_name:
        print(f"Found {target_name} in {employee['department']}")
        break
else:
    print(f"{target_name} not found.")

Building a Multiplication Table

size = 10

# Print header row
print("     ", end="")
for j in range(1, size + 1):
    print(f"{j:4}", end="")
print()
print("    " + "-" * (size * 4))

# Print each row
for i in range(1, size + 1):
    print(f"{i:3} |", end="")
    for j in range(1, size + 1):
        print(f"{i * j:4}", end="")
    print()

Processing User Input Until Quit

tasks = []

while True:
    action = input("Enter 'add', 'list', or 'quit': ").strip().lower()

    if action == "quit":
        print(f"Exiting. You have {len(tasks)} task(s).")
        break
    elif action == "add":
        task = input("Enter task description: ").strip()
        if task:
            tasks.append(task)
            print(f"Added: {task}")
        else:
            print("Task cannot be empty.")
    elif action == "list":
        if tasks:
            for i, task in enumerate(tasks, start=1):
                print(f"  {i}. {task}")
        else:
            print("No tasks yet.")
    else:
        print("Unknown command. Try again.")

Iterating Over a Dictionary to Build a Report

sales_data = {
    "January": 15000,
    "February": 18500,
    "March": 22000,
    "April": 19750,
    "May": 24300
}

print("Monthly Sales Report")
print("=" * 30)

total_sales = 0
best_month = ""
best_amount = 0

for month, amount in sales_data.items():
    print(f"  {month:12s} ${amount:,.2f}")
    total_sales += amount
    if amount > best_amount:
        best_amount = amount
        best_month = month

print("=" * 30)
print(f"  {'Total':12s} ${total_sales:,.2f}")
print(f"\nBest month: {best_month} (${best_amount:,.2f})")

The pass Statement

Loop bodies in Python cannot be empty. If you need a placeholder — perhaps during development when you haven't written the logic yet — use pass.

for item in range(10):
    pass  # TODO: implement processing logic

This is also useful as a no-op in exception handling within loops:

data = ["10", "abc", "30", "xyz", "50"]
numbers = []

for item in data:
    try:
        numbers.append(int(item))
    except ValueError:
        pass  # silently skip non-numeric values

print(numbers)  # [10, 30, 50]

Common Pitfalls

1. Infinite Loops

Forgetting to update the loop variable in a while loop is the most common source of infinite loops.

# BUG: count is never incremented — this runs forever
count = 0
while count < 10:
    print(count)
    # Missing: count += 1

# FIX: always ensure the condition variable changes
count = 0
while count < 10:
    print(count)
    count += 1

If you accidentally create an infinite loop in a terminal, press Ctrl + C to interrupt it.

2. Modifying a List While Iterating

Never add or remove elements from a list while iterating over it. This leads to skipped elements or index errors.

# BUG: modifying list during iteration
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)  # skips elements!
print(numbers)  # [1, 3, 5] — looks correct but 4 was never checked

# FIX: iterate over a copy, or build a new list
numbers = [1, 2, 3, 4, 5, 6]
numbers = [num for num in numbers if num % 2 != 0]
print(numbers)  # [1, 3, 5]

# Alternative: iterate over a copy using slicing
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers[:]:  # numbers[:] creates a shallow copy
    if num % 2 == 0:
        numbers.remove(num)
print(numbers)  # [1, 3, 5]

3. Off-by-One Errors with range()

range() is exclusive of the stop value. This trips up developers who expect inclusive behavior.

# Want to print 1 through 5
for i in range(5):
    print(i)  # Prints 0, 1, 2, 3, 4 — NOT 1 through 5

# FIX: adjust start and stop
for i in range(1, 6):
    print(i)  # Prints 1, 2, 3, 4, 5

Best Practices

  • Use for loops when the number of iterations is known — iterating over a collection, a range, or any finite sequence.
  • Use while loops for condition-based repetition — when you genuinely don't know how many iterations you need (user input, convergence algorithms, event polling).
  • Prefer enumerate() over manual index tracking — it is more readable, less error-prone, and explicitly communicates your intent.
  • Use zip() for parallel iteration — instead of indexing into multiple lists, zip them together for cleaner code.
  • Use list comprehensions for simple transformations — they are concise and performant. But don't overuse them: if a comprehension exceeds one line or requires complex logic, use a regular loop.
  • Name your loop variables meaningfullyfor student in students reads better than for s in students or for i in students.
  • Avoid deep nesting — if you find yourself writing three or more levels of nested loops, consider breaking the logic into helper functions.
  • Use break and continue judiciously — they are powerful but can make flow harder to follow if overused. One break per loop is a reasonable guideline.

Key Takeaways

  1. for loops iterate over sequences (lists, strings, ranges, dictionaries) and are your default choice for most iteration needs.
  2. while loops run as long as a condition is true — ideal for input processing, polling, or when the iteration count is unknown.
  3. break exits the loop immediately; continue skips to the next iteration.
  4. The else clause on a loop runs only if the loop completed without a break — useful for search patterns.
  5. enumerate() gives you index-value pairs without manual counters.
  6. zip() lets you iterate over multiple sequences in parallel.
  7. List comprehensions offer a concise one-liner alternative for building lists from loops.
  8. Watch out for infinite loops, modifying collections during iteration, and off-by-one errors with range().
  9. Write loops with clear variable names and minimal nesting — your future self (and your teammates) will thank you.

 

Source code on Github

March 8, 2021

Python – Introduction

What is Python?

Python is a general-purpose, high-level programming language that has become the default tool for a massive range of software work — from web backends and automation scripts to machine learning pipelines and data analysis. It is open source, cross-platform (runs on Windows, macOS, and Linux), and supports both object-oriented and procedural programming paradigms.

What makes Python stand out is its readability. The syntax is clean and concise, which means you spend less time fighting the language and more time solving actual problems. If you are coming from a language like Java or C++, you will notice immediately how much less boilerplate you need to write. And if Python is your first language, you are starting in a great place — the learning curve is gentle, but the ceiling is very high.

Why Python Matters for Your Career

Let me be direct: learning Python is one of the highest-leverage investments you can make as a developer. Here is why.

Python is not just popular — it is dominant in some of the fastest-growing areas of tech. Data science, machine learning, AI, automation, cloud infrastructure, and backend web development all lean heavily on Python. When companies like Google, Netflix, Instagram, Spotify, and Dropbox need to move fast and build reliable systems, Python is a core part of their stack.

From a job market perspective, Python consistently ranks in the top 2-3 most in-demand programming languages. Whether you want to work at a startup building an MVP or at a Fortune 500 company scaling infrastructure, Python skills open doors. Developers with Python expertise command strong salaries, especially in data science and machine learning roles.

According to iDataLabs, 67% of the companies that use Python are small (<$50M in revenue), 9% are medium-sized ($50M – $1000M in revenue), and 17% are large (>$1000M in revenue). This tells you something important: Python works at every scale.

Python usage across industries

Python in the Real World

To give you a concrete sense of what you can build with Python, here are real-world examples across different domains:

  • Web Applications — Django and Flask are Python web frameworks used in production by major companies. Instagram’s entire backend runs on Django. You can go from zero to a deployed web app in a weekend.
  • Data Analysis and Visualization — Libraries like Pandas, NumPy, and Matplotlib let you clean, analyze, and visualize datasets. Analysts at companies like JPMorgan and Goldman Sachs use Python daily.
  • Machine Learning and AI — TensorFlow, PyTorch, and scikit-learn are all Python-first. If you want to build recommendation engines, image classifiers, or natural language processing systems, Python is the standard.
  • Automation and Scripting — Need to rename 10,000 files, scrape data from websites, or automate a deployment pipeline? Python scripts handle this in a few lines of code.
  • API Development — FastAPI and Flask make it straightforward to build REST APIs that serve mobile apps, frontend clients, or microservices.
  • DevOps and Cloud — Tools like Ansible (written in Python) and AWS’s Boto3 SDK let you manage cloud infrastructure programmatically.

The point is: Python is not a toy language. It is production-grade software that powers critical systems worldwide.

Companies That Use Python

Companies that use Python

Installation

Before you write any Python code, you need Python installed on your machine. Here is how to do it on each major operating system.

macOS

macOS comes with Python pre-installed, but it is often an outdated version. Install the latest version using Homebrew (the standard package manager for macOS):

# Install Homebrew if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install Python
brew install python

# Verify the installation
python3 --version

Windows

Download the official installer from python.org/downloads. During installation, check the box that says “Add Python to PATH” — this is critical. Then open Command Prompt or PowerShell and verify:

# Verify the installation
python --version

Linux (Ubuntu/Debian)

Most Linux distributions come with Python, but you can install or update it via your package manager:

# Update package list and install Python
sudo apt update
sudo apt install python3 python3-pip

# Verify the installation
python3 --version

Once you see a version number (e.g., Python 3.12.x), you are ready to go.

Python REPL (Interactive Mode)

One of Python’s best features for learning is the REPL — Read, Eval, Print, Loop. It is an interactive shell where you can type Python code and see the result immediately. No files to create, no compilation step. Just instant feedback.

Open your terminal and type python3 (or python on Windows):

$ python3
Python 3.12.0 (main, Oct  2 2023, 00:00:00)
>>> 2 + 2
4
>>> "hello".upper()
'HELLO'
>>> len([1, 2, 3, 4, 5])
5
>>> exit()

The >>> prompt means Python is waiting for your input. You can use the REPL to test ideas, explore libraries, and debug code snippets. I still use it daily, even after years of professional development. To exit, type exit() or press Ctrl+D (macOS/Linux) or Ctrl+Z then Enter (Windows).

Your First Python Programs

Let us start with the classic and then build up from there. Create a file called hello.py and add the following:

# hello.py
print("Hello, World!")

Run it from your terminal:

$ python3 hello.py
Hello, World!

That is it. No class declarations, no main method, no semicolons. Just one line of code that does exactly what you expect. Now let us go a bit further.

Working with Variables and Basic Math

# variables.py

# Python figures out the type automatically -- no need to declare it
name = "Folau"
age = 30
height = 5.11

# Basic arithmetic
years_until_retirement = 65 - age

print(name)                      # Folau
print(age)                       # 30
print(years_until_retirement)    # 35

# Python handles big numbers and math naturally
total_seconds_in_a_day = 60 * 60 * 24
print(total_seconds_in_a_day)    # 86400

String Formatting (f-strings)

f-strings are the modern, clean way to embed variables inside strings. You will use these constantly.

# formatting.py
name = "Folau"
language = "Python"
experience_years = 5

# f-string syntax -- put an f before the quote and use curly braces
message = f"Hi, I'm {name} and I've been writing {language} for {experience_years} years."
print(message)
# Output: Hi, I'm Folau and I've been writing Python for 5 years.

# You can put expressions inside the braces too
price = 49.99
tax_rate = 0.08
total = price * (1 + tax_rate)
print(f"Total with tax: ${total:.2f}")
# Output: Total with tax: $53.99

Lists and Loops

# lists_and_loops.py
languages = ["Python", "Java", "JavaScript", "Go", "Rust"]

# Loop through a list
for lang in languages:
    print(f"I want to learn {lang}")

# List comprehension -- a Pythonic way to create new lists
uppercase_languages = [lang.upper() for lang in languages]
print(uppercase_languages)
# Output: ['PYTHON', 'JAVA', 'JAVASCRIPT', 'GO', 'RUST']

# Filtering with a condition
long_names = [lang for lang in languages if len(lang) > 4]
print(long_names)
# Output: ['Python', 'JavaScript']

As you can see, Python’s syntax is remarkably close to plain English. The code reads almost like pseudocode, which is a huge advantage when you are learning or when you revisit code months later.

Python Is Easy to Read, Write, and Learn

Python was designed with one clear philosophy: readability counts. The language enforces clean indentation, avoids unnecessary punctuation, and provides a standard library that covers most common tasks out of the box.

Compared to languages like Java or C++, there is far less ceremony involved in getting something working. No public static void main, no header files, no manual memory management. You write what you mean, and it works.

Python is also an interpreted language. This means you can run each line of code as soon as you finish writing it and see the results immediately. This is especially valuable when you are learning — you get instant feedback instead of waiting for a compilation step.

Most wanted programming languages

Python Is the Fastest Growing Programming Language

Python’s explosive growth in data science, machine learning, and AI has pushed it onto a trajectory that no other language matches right now. Stack Overflow’s analysis of visitor traffic and question volume consistently shows Python as the fastest-growing major programming language.

Python growth on Stack Overflow

Python Developers Make Great Money

Python developers are among the highest-paid in the industry, particularly in data science, machine learning, and backend web development. The demand consistently outpaces supply, which keeps compensation strong.

Python vs Java developer salaries

Python Has a Great Community

One of the most underrated aspects of choosing a programming language is the quality of its community. When you hit a wall (and you will), having millions of developers who have encountered and solved the same problem is invaluable.

Python has over one million tagged questions on Stack Overflow and more than 1.5 million repositories on GitHub with over 90,000 active contributors. Whether you need help debugging an error, understanding a library, or reviewing best practices, the Python community has your back.

GitHub most popular programming languages

Best Practices for Beginners

Before you dive deeper into Python, here are habits that will save you time and frustration down the road. These are the things I wish someone had told me on day one.

  1. Use Virtual Environments — Always create a virtual environment for each project. This keeps your dependencies isolated so that Project A’s libraries do not conflict with Project B’s. Use python3 -m venv venv to create one, and source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows) to activate it.
  2. Follow PEP 8 — PEP 8 is Python’s official style guide. It covers naming conventions, indentation, line length, and more. Consistent style makes your code easier to read and maintain. Most modern editors can auto-format your code to PEP 8 standards.
  3. Use Meaningful Variable Namesx = 10 tells you nothing. max_retries = 10 tells you everything. Write code for the person who will read it six months from now (that person is usually you).
  4. Read Error Messages Carefully — Python’s error messages (tracebacks) are actually very helpful. Read them from the bottom up. The last line tells you what went wrong, and the lines above show you where it happened.
  5. Learn to Use pippip is Python’s package manager. Use pip install package_name to add libraries and pip freeze > requirements.txt to save your project’s dependencies so others can replicate your environment.
  6. Write Small, Testable Functions — Break your code into small functions that each do one thing. This makes your code easier to test, debug, and reuse.

Key Takeaways

  • Python is a general-purpose language used in web development, data science, machine learning, automation, and more.
  • Its clean, readable syntax makes it one of the best first languages to learn — but it is also powerful enough for production systems at any scale.
  • Installation is straightforward on all major operating systems. Use the REPL for quick experimentation.
  • Python’s job market is strong and growing. Skills in Python open doors across industries from startups to enterprise.
  • Start with good habits early: virtual environments, PEP 8, meaningful names, and small functions will pay dividends as your projects grow.
  • The Python community is massive and welcoming. When you get stuck, the answer is almost always one search away.

In the next tutorial, we will dive deeper into Python’s data types and control flow. For now, get Python installed, open the REPL, and start experimenting. The best way to learn is to write code.

March 8, 2021