Collections #
The collections module provides alternative container data types designed to solve common patterns that feel awkward with plain dict, list, or tuple. You don’t need to write boilerplate for counting frequencies, initializing default values, or creating two-way queues — everything is available with a clean interface. Understanding this module is one of the most effective ways to make your Python code more concise and expressive.
To make it easier to pick the right special container from the collections module, consider the decision flow below:
flowchart TD
Start["Special Data Structure Need"] --> Q1{"Need a default value if the key is missing?"}
Q1 -->|"Yes"| DefaultDict["defaultdict"]
Q1 -->|"No"| Q2{"Need to count element occurrences?"}
Q2 -->|"Yes"| Counter["Counter"]
Q2 -->|"No"| Q3{"Need a queue/stack with fast append/pop at both ends?"}
Q3 -->|"Yes"| Deque["deque"]
Q3 -->|"No"| Q4{"Need a tuple with field access by name?"}
Q4 -->|"Yes"| NamedTuple["namedtuple"]
Q4 -->|"No"| StandardContainer["Use standard dict / list / tuple"]Counter — Counting Frequencies #
Counter is a dict subclass specifically designed to count element occurrences. It’s very useful for text analysis, simple statistics, or anything involving frequency counting.
from collections import Counter
# ANTI-PATTERN: counting frequencies manually with a plain dict
kata = ["apel", "jeruk", "apel", "mangga", "jeruk", "apel"]
frekuensi = {}
for buah in kata:
if buah in frekuensi:
frekuensi[buah] += 1
else:
frekuensi[buah] = 0
# CORRECT: use Counter
frekuensi = Counter(kata)
print(frekuensi)
# Counter({'apel': 3, 'jeruk': 2, 'mangga': 1})
Creating a Counter #
from collections import Counter
# From an iterable
c1 = Counter("mississippi")
print(c1) # Counter({'s': 4, 'i': 4, 'p': 2, 'm': 1})
# From a dictionary
c2 = Counter({"merah": 3, "biru": 2})
# From keyword arguments
c3 = Counter(kucing=5, anjing=2, burung=1)
# A missing Counter key returns 0, not KeyError
print(c3["ikan"]) # 0 -- not KeyError
Useful Counter Methods #
from collections import Counter
teks = "the quick brown fox jumps over the lazy dog the fox"
c = Counter(teks.split())
# most_common(n) -- the n most frequent elements
print(c.most_common(3))
# [('the', 3), ('fox', 2), ('quick', 1)]
# most_common() without arguments -- all elements, ordered most to least
print(c.most_common())
# elements() -- iterator repeating each element by its count
print(list(Counter("aab").elements())) # ['a', 'a', 'b']
# update() -- add counts
c.update(["the", "fox", "fox"])
print(c["the"]) # 4
print(c["fox"]) # 4
# subtract() -- subtract counts (can go negative)
c.subtract(["the", "the"])
print(c["the"]) # 2
Counter Arithmetic #
from collections import Counter
a = Counter(apel=3, jeruk=2, mangga=1)
b = Counter(apel=1, jeruk=4, pisang=2)
print(a + b) # Counter({'jeruk': 6, 'apel': 4, 'pisang': 2, 'mangga': 1})
print(a - b) # Counter({'apel': 2, 'mangga': 1}) -- negatives dropped
print(a & b) # Counter({'jeruk': 2, 'apel': 1}) -- minimum per element
print(a | b) # Counter({'jeruk': 4, 'apel': 3, 'pisang': 2, 'mangga': 1}) -- maximum
defaultdict — Dict with Default Values #
defaultdict solves the KeyError problem that often occurs when you try to access or update a key that doesn’t exist in a dictionary.
from collections import defaultdict
# ANTI-PATTERN: manual checks before updating
groups = {}
data = [("buah", "apel"), ("sayur", "wortel"), ("buah", "jeruk"), ("sayur", "bayam")]
for kategori, item in data:
if kategori not in groups:
groups[kategori] = []
groups[kategori].append(item)
# CORRECT: use defaultdict
groups = defaultdict(list)
for kategori, item in data:
groups[kategori].append(item) # no need to check first
print(dict(groups))
# {'buah': ['apel', 'jeruk'], 'sayur': ['wortel', 'bayam']}
Commonly Used Default Types #
from collections import defaultdict
# defaultdict(list) -- the default value is an empty list
dd_list = defaultdict(list)
dd_list["a"].append(1)
dd_list["a"].append(2)
dd_list["b"].append(3)
print(dict(dd_list)) # {'a': [1, 2], 'b': [3]}
# defaultdict(int) -- the default value is 0 (from int())
dd_int = defaultdict(int)
for huruf in "banana":
dd_int[huruf] += 1
print(dict(dd_int)) # {'b': 1, 'a': 3, 'n': 2}
# defaultdict(set) -- the default value is an empty set
dd_set = defaultdict(set)
dd_set["warna"].add("merah")
dd_set["warna"].add("biru")
dd_set["ukuran"].add("besar")
print(dict(dd_set)) # {'warna': {'merah', 'biru'}, 'ukuran': {'besar'}}
# defaultdict(dict) -- the default value is an empty dict
dd_dict = defaultdict(dict)
dd_dict["user1"]["nama"] = "Budi"
dd_dict["user1"]["usia"] = 28
print(dict(dd_dict)) # {'user1': {'nama': 'Budi', 'usia': 28}}
Using a Custom Function as the Default #
from collections import defaultdict
# A lambda or any callable function
dd = defaultdict(lambda: "tidak diketahui")
dd["nama"] = "Budi"
print(dd["nama"]) # "Budi"
print(dd["kota"]) # "tidak diketahui" -- new key created with the default value
# Nested defaultdict for hierarchical structures
nested = defaultdict(lambda: defaultdict(int))
nested["Jakarta"]["Selatan"] += 5
nested["Jakarta"]["Utara"] += 3
nested["Bandung"]["Barat"] += 2
print(nested["Jakarta"]["Selatan"]) # 5
print(nested["Surabaya"]["Pusat"]) # 0 -- created automatically
Accessing a missing key in adefaultdictcreates a new key with the default value. This differs fromdict.get(), which only returns a value without creating a key. If you only want to read a value without side effects, usedd.get(key, default).
deque — Two-Way Queue #
deque (double-ended queue) is a data structure optimized for add and remove operations at both ends with O(1) complexity. It’s far more efficient than a list for operations at the left end.
from collections import deque
# ANTI-PATTERN: using a list as a queue
antrian = []
antrian.append("a") # add on the right -- O(1)
antrian.insert(0, "b") # add on the left -- O(n), slow for large lists!
antrian.pop(0) # remove on the left -- O(n), slow for large lists!
# CORRECT: use deque
antrian = deque()
antrian.append("a") # add on the right -- O(1)
antrian.appendleft("b") # add on the left -- O(1)
antrian.pop() # remove on the right -- O(1)
antrian.popleft() # remove on the left -- O(1)
deque Operations #
from collections import deque
d = deque([1, 2, 3, 4, 5])
# Add elements
d.append(6) # [1, 2, 3, 4, 5, 6]
d.appendleft(0) # [0, 1, 2, 3, 4, 5, 6]
d.extend([7, 8]) # [0, 1, 2, 3, 4, 5, 6, 7, 8]
d.extendleft([-2, -1]) # [-1, -2, 0, 1, 2, 3, 4, 5, 6, 7, 8] -- note the reversed order
# Remove elements
d.pop() # remove from the right
d.popleft() # remove from the left
d.remove(3) # remove the first occurrence of value 3
# Rotation
d = deque([1, 2, 3, 4, 5])
d.rotate(2) # [4, 5, 1, 2, 3] -- shift 2 to the right
d.rotate(-1) # [5, 1, 2, 3, 4] -- shift 1 to the left
deque with maxlen — Sliding Window #
maxlen makes a deque with a fixed capacity — old elements are automatically discarded when new ones come in. Very useful for implementing sliding windows or storing the last N logs.
from collections import deque
# Keep only the last 5 logs
log = deque(maxlen=5)
for i in range(10):
log.append(f"event-{i}")
print(list(log))
# event-0: ['event-0']
# event-1: ['event-0', 'event-1']
# ...
# event-5: ['event-1', 'event-2', 'event-3', 'event-4', 'event-5'] -- event-0 discarded
# event-9: ['event-5', 'event-6', 'event-7', 'event-8', 'event-9']
# Simple moving average
def moving_average(data: list, window: int):
buffer = deque(maxlen=window)
hasil = []
for nilai in data:
buffer.append(nilai)
hasil.append(sum(buffer) / len(buffer))
return hasil
print(moving_average([1, 2, 3, 4, 5, 6], window=3))
# [1.0, 1.5, 2.0, 3.0, 4.0, 5.0]
namedtuple — Tuple with Named Fields #
namedtuple creates a tuple whose fields can be accessed by name, not just index. This makes code easier to read without the memory overhead of a full class.
from collections import namedtuple
# ANTI-PATTERN: plain tuple, unclear what the fields are
koordinat = (10.5, -6.2)
print(koordinat[0]) # what is this? latitude? longitude? x? y?
# CORRECT: namedtuple gives clear names
Titik = namedtuple("Titik", ["x", "y"])
p = Titik(x=10.5, y=-6.2)
print(p.x) # 10.5 -- clearly this is x
print(p.y) # -6.2
print(p) # Titik(x=10.5, y=-6.2)
Defining and Using namedtuple #
from collections import namedtuple
# Definition -- various ways to write fields
Mahasiswa = namedtuple("Mahasiswa", ["nama", "nim", "ipk"])
Produk = namedtuple("Produk", "id nama harga stok") # can use a space-separated string
# Create instances
mhs = Mahasiswa(nama="Budi", nim="2021001", ipk=3.75)
produk = Produk(id=1, nama="Laptop", harga=15000000, stok=10)
# Access by name or index
print(mhs.nama) # "Budi"
print(mhs[0]) # "Budi" -- index access still works
print(mhs.ipk) # 3.75
# Tuple unpacking still works
nama, nim, ipk = mhs
print(nama) # "Budi"
# _asdict() -- convert to a dict
print(mhs._asdict())
# {'nama': 'Budi', 'nim': '2021001', 'ipk': 3.75}
# _replace() -- create a copy with some fields changed (immutable!)
mhs_baru = mhs._replace(ipk=3.90)
print(mhs_baru) # Mahasiswa(nama='Budi', nim='2021001', ipk=3.9)
print(mhs.ipk) # 3.75 -- the original is unchanged
namedtuple vs dict vs dataclass #
# namedtuple: immutable, memory-efficient, good for simple data
Titik = namedtuple("Titik", ["x", "y"])
p = Titik(1, 2)
# p.x = 3 -- AttributeError: can't be modified
# dict: mutable, flexible, but less expressive access
p = {"x": 1, "y": 2}
p["x"] = 3 # can be modified
# dataclass (Python 3.7+): mutable by default, more complete features
# -- covered in the Dataclasses article
For more complex needs — like default values, validation, or methods — considerdataclasses.dataclass(covered in a separate article).namedtupleis best for simple data that doesn’t need to change after creation.
OrderedDict — Dict That Remembers Insertion Order #
Since Python 3.7, the built-in dict already preserves insertion order, so OrderedDict is no longer needed for that purpose. However, OrderedDict is still relevant for two specific cases: when you need move_to_end() or when comparing two dicts with order taken into account.
from collections import OrderedDict
# For regular ordered dict needs -- the standard dict is enough (Python 3.7+)
d = {"a": 1, "b": 2, "c": 3} # order preserved
# OrderedDict is still useful for move_to_end()
od = OrderedDict([("a", 1), ("b", 2), ("c", 3)])
od.move_to_end("a") # move "a" to the end
print(list(od.keys())) # ['b', 'c', 'a']
od.move_to_end("a", last=False) # move to the front
print(list(od.keys())) # ['a', 'b', 'c']
# OrderedDict accounts for order when comparing
od1 = OrderedDict([("a", 1), ("b", 2)])
od2 = OrderedDict([("b", 2), ("a", 1)])
print(od1 == od2) # False -- different order considered unequal
d1 = {"a": 1, "b": 2}
d2 = {"b": 2, "a": 1}
print(d1 == d2) # True -- plain dicts don't care about order
Simple LRU Cache with OrderedDict #
from collections import OrderedDict
class LRUCache:
"""Least Recently Used cache with fixed capacity."""
def __init__(self, kapasitas: int):
self.kapasitas = kapasitas
self.cache = OrderedDict()
def get(self, key):
if key not in self.cache:
return None
self.cache.move_to_end(key) # mark as recently used
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.kapasitas:
self.cache.popitem(last=False) # evict the least recently used
cache = LRUCache(3)
cache.put("a", 1)
cache.put("b", 2)
cache.put("c", 3)
cache.get("a") # access "a", moves it to the end
cache.put("d", 4) # "b" evicted because it was least recently used
print(list(cache.cache.keys())) # ['c', 'a', 'd']
ChainMap — Combining Multiple Dicts #
ChainMap combines several mappings into one unified view without copying data. Lookups are done sequentially from the first mapping.
from collections import ChainMap
# Example case: configuration with priorities
default_config = {"debug": False, "port": 8080, "host": "localhost"}
env_config = {"port": 9000} # override from the environment
user_config = {"debug": True} # override from the user
# ChainMap looks up from left to right
config = ChainMap(user_config, env_config, default_config)
print(config["debug"]) # True -- from user_config
print(config["port"]) # 9000 -- from env_config
print(config["host"]) # "localhost" -- from default_config
# Updates only affect the first mapping
config["timeout"] = 30
print(user_config) # {'debug': True, 'timeout': 30}
print(default_config) # unchanged
Summary #
Counterfor counting element frequencies — more concise than a manual dict. Usemost_common(n)for the top-N elements.defaultdictfor dicts that automatically create default values when new keys are accessed — avoids theif key not in dboilerplate. Remember: accessing a missing key creates it.dequefor an efficient two-way queue —appendleft()andpopleft()are O(1), unlikelist.insert(0)which is O(n). Usemaxlenfor sliding windows or fixed-size buffers.namedtuplefor lightweight immutable structured data — more expressive than a plain tuple, more memory-efficient than a full class. Use_replace()to create copies with different fields.OrderedDictis still relevant formove_to_end()and order-aware dict comparison, even though plaindicthas preserved insertion order since Python 3.7.ChainMapfor combining multiple dicts with priority — useful for layered configuration systems.