How to work with dictionaries in Python

How to work with dictionaries in Python

Dictionaries in Python are like the ultimate lookup tables. You get a key, you get a value-period. No fuss, no muss. They’re unordered collections of key-value pairs, but that’s just the start. The real power comes from how fast and flexible they are.

At the core, creating a dictionary is straightforward:

my_dict = {
  "apple": 3,
  "banana": 5,
  "orange": 2
}

Here, the keys are strings, but keys can be any immutable type-numbers, tuples, even frozensets. Values? Anything goes. The moment you use a key, Python hashes it and does a lightning-fast lookup. This makes dictionaries incredibly efficient for frequent queries.

Accessing values is as simple as:

count = my_dict["apple"]
print(count)  # Outputs: 3

But beware: accessing a key that doesn’t exist throws a KeyError. To avoid that, either use in to check:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

Or use get with a default value, which is often cleaner:

print(my_dict.get("pear", 0))  # Outputs: 0 instead of KeyError

Adding or updating entries is just as intuitive:

my_dict["pear"] = 4       # Adds new key-value pair
my_dict["banana"] = 6     # Updates the value for existing key

If you want to remove items, del is your friend:

del my_dict["orange"]

This raises a KeyError if the key doesn’t exist, so sometimes pop is better because it lets you specify a default:

removed = my_dict.pop("orange", None)
if removed is None:
    print("No orange found to remove")

Iterating over dictionaries can be done in three main ways, each with its own use case:

for key in my_dict:
    print(key)

for key, value in my_dict.items():
    print(key, value)

for value in my_dict.values():
    print(value)

Remember, before Python 3.7, dictionaries weren’t guaranteed to maintain insertion order, but now they do. This subtlety is important if order matters in your application.

Another handy method is setdefault. It’s like a combo of checking and inserting:

count = my_dict.setdefault("grape", 0)
my_dict["grape"] += 1

This initializes the key “grape” with 0 if it doesn’t exist, otherwise returns its current value. You can use this trick for counting or grouping without verbose checks.

Copying dictionaries requires attention because a simple assignment just points to the same object:

a = {"x": 1}
b = a
b["x"] = 2
print(a["x"])  # Outputs: 2 because both point to same dict

To actually copy, use copy() or the dict() constructor:

count = my_dict["apple"]
print(count)  # Outputs: 3

But beware: copy() is shallow. If your dictionary values contain mutable objects, you might need deepcopy from the copy module for a full independent clone.

Sorting dictionaries is a common question. Dictionaries themselves don’t sort in place because they’re inherently unordered (or ordered by insertion), but you can sort their items and create ordered views:

count = my_dict["apple"]
print(count)  # Outputs: 3

This way, you can process entries by frequency, alphabetically, or whatever metric you choose.

One last trick for basic merging is the update() method:

count = my_dict["apple"]
print(count)  # Outputs: 3

This overwrites existing keys with values from other. For Python 3.9+, there’s a cleaner syntax:

count = my_dict["apple"]
print(count)  # Outputs: 3

which returns a new dictionary without modifying my_dict. The in-place version is:

count = my_dict["apple"]
print(count)  # Outputs: 3

These operators make it succinct to combine dictionaries without boilerplate code but watch out for version compatibility.

Understanding these basics gives you a solid foundation. Next, we can dive into more advanced patterns that make your code not just functional but elegant and idiomatic. For example, using dictionary comprehensions to replace loops or leveraging defaultdict for cleaner counting and grouping.

Here’s a quick taste of dictionary comprehensions:

count = my_dict["apple"]
print(count)  # Outputs: 3

They’re not just syntactic sugar-they can dramatically reduce the lines and boost readability.

Moving beyond the basics, if you find yourself writing a lot of code to handle missing keys or nested dictionaries, the collections module offers defaultdict and Counter that are indispensable.

For example, counting occurrences with Counter:

count = my_dict["apple"]
print(count)  # Outputs: 3

This is cleaner and faster than manually incrementing counts with setdefault or get.

Or using defaultdict to avoid manual checks:

count = my_dict["apple"]
print(count)  # Outputs: 3

This pattern replaces clunky if-key-in-dict checks with neat, declarative code.

Meanwhile, when you want to merge dictionaries but also process conflicting keys, things get trickier. You’ll often find yourself writing loops like:

count = my_dict["apple"]
print(count)  # Outputs: 3

But with Counter, this reduces to:

count = my_dict["apple"]
print(count)  # Outputs: 3

Which is not only concise but faster and more expressive.

One subtle but useful trick is to use tuples as keys for composite indexing. Say you want to count occurrences of pairs:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

This counts exact pairs without nesting dictionaries, which quickly becomes a mess:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

Much more verbose and error-prone, especially if you forget to initialize your inner dictionaries.

Dictionaries are everywhere in Python-configuration objects, JSON parsing, memoization caches. Mastering their use early saves you from headaches later. And since they’re mutable, be mindful whenever you pass them around functions or store them as defaults.

A common mistake is using mutable defaults in function definitions:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

The default dict persists across calls, causing unexpected shared state. The correct pattern:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

It’s a subtle bug but common enough to trip up even experienced devs.

There’s also the less-known dict.fromkeys() method, which creates a new dictionary with specified keys all set to the same value:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

This is handy when you want to initialize a dictionary quickly without writing out all keys manually.

But watch out if your default value is mutable:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

This happens because the same list object is assigned to all keys. If you want separate lists, you’ll need a comprehension:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

Little gotchas like these are why understanding dictionary internals and behavior pays off.

As you work with dictionaries daily, you’ll start to appreciate their versatility. From simple lookups to complex data structures, they’re your Swiss Army knife in Python. Next up, we’ll explore how to leverage them to write cleaner, more idiomatic code-cutting down boilerplate and bugs by using advanced techniques like comprehensions, chaining, and custom subclasses.

For instance, dictionary comprehensions combined with conditional logic can replace clunky loops:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

Or, you can invert dictionaries to swap keys and values (assuming values are unique):

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

These idioms become second nature once you see how often they fit naturally into real problems. But there’s a catch: sometimes your values aren’t unique, and naive inversion loses data. That’s when you start using collections like defaultdict(list) to gather multiple keys per value:

if "pear" in my_dict:
    print(my_dict["pear"])
else:
    print("Pear not found")

This approach preserves all mappings without losing information.

Finally, remember that dictionaries are mutable and passed by reference. When you pass a dictionary to a function, any changes affect the original dict. If you want to avoid side effects, explicitly copy before modifying:

print(my_dict.get("pear", 0))  # Outputs: 0 instead of KeyError

Failing to do this can cause subtle bugs, especially in large codebases where dictionaries flow through many layers.

There’s a lot more under the hood, but understanding these fundamental operations and behaviors sets you up to write robust, efficient Python code with dictionaries. From here, it’s a matter of layering on more sophisticated patterns and knowing when to use which tool.

To wrap up this section, one last nifty trick: you can use dictionaries for memoization, caching function results to avoid expensive recomputation:

print(my_dict.get("pear", 0))  # Outputs: 0 instead of KeyError

Although this works, beware of the mutable default argument trap mentioned earlier. A better approach is:

print(my_dict.get("pear", 0))  # Outputs: 0 instead of KeyError

This pattern is essential for dynamic programming problems and highlights how dictionaries can serve as fast, flexible caches.

Next, we’ll see how to push dictionaries further-making your code cleaner and more declarative with advanced techniques that avoid repetitive boilerplate and common pitfalls like deep nesting or manual key checking. But first, a little about chaining and merging…

Mastering advanced dictionary techniques for cleaner code

When you need to combine dictionaries, especially for things like configuration settings where you have defaults and user-specific overrides, you could merge them. But merging creates a whole new dictionary, which can be inefficient if the dictionaries are large. A better tool for this job is collections.ChainMap. It groups multiple dictionaries into a single, updateable view without copying any data.

from collections import ChainMap

defaults = {'theme': 'dark', 'font_size': 12}
user_prefs = {'font_size': 14, 'show_toolbar': True}

config = ChainMap(user_prefs, defaults)

print(config['font_size'])      # Outputs: 14 (from user_prefs)
print(config['theme'])          # Outputs: 'dark' (from defaults)
print(config['show_toolbar'])   # Outputs: True (from user_prefs)

The lookup searches each dictionary in the chain until it finds the key. The real magic is that writes, updates, and deletions only ever operate on the first dictionary in the chain. This makes it perfect for managing contexts or scopes, where you want to temporarily override a setting without modifying the underlying defaults.

# Adding a new setting only affects user_prefs
config['language'] = 'en'
print(user_prefs)  # Outputs: {'font_size': 14, 'show_toolbar': True, 'language': 'en'}
print(defaults)    # Outputs: {'theme': 'dark', 'font_size': 12} (unchanged)

This is fundamentally different from the merge operator (|), which creates a new, flat dictionary. ChainMap is a live view. If you modify one of the underlying dictionaries in the chain (other than the first one), the changes are reflected in the ChainMap. It’s a powerful tool for avoiding data duplication and managing layered configurations cleanly.

Sometimes, you need more than what a standard dictionary offers. You might want a dictionary that automatically converts its keys to lowercase for case-insensitive access, or one that logs every access. You can achieve this by subclassing dict and overriding its methods. It’s less scary than it sounds.

class CaseInsensitiveDict(dict):
    def __setitem__(self, key, value):
        super().__setitem__(key.lower(), value)

    def __getitem__(self, key):
        return super().__getitem__(key.lower())

    def __contains__(self, key):
        return super().__contains__(key.lower())

ci_dict = CaseInsensitiveDict()
ci_dict['Name'] = 'Joel'
print(ci_dict['name'])  # Outputs: 'Joel'
print('NAME' in ci_dict) # Outputs: True

This simple subclass gives you a reusable, case-insensitive dictionary without cluttering your application logic with .lower() calls everywhere. You can override any dictionary method this way to add custom behavior, creating specialized data structures that fit your exact needs.

Another detail that often trips people up is that .keys(), .values(), and .items() do not return lists. They return “dictionary view” objects. A view is a dynamic window into the dictionary’s entries. If the dictionary changes, the view reflects those changes immediately. This is efficient because it avoids copying data, but it can lead to unexpected behavior if you’re not aware of it.

data = {'a': 1, 'b': 2}
items_view = data.items()

print(items_view)  # Outputs: dict_items([('a', 1), ('b', 2)])

# Now, modify the original dictionary
data['c'] = 3

print(items_view)  # Outputs: dict_items([('a', 1), ('b', 2), ('c', 3)])

The view updated automatically. If you need a static snapshot of the items at a particular moment, you have to explicitly convert the view to a list: list(data.items()). Understanding this distinction is crucial for writing bug-free code, especially when you’re iterating over a dictionary that might be modified elsewhere.

With Python 3.10, dictionaries became even more powerful with the introduction of structural pattern matching. The match...case statement lets you destructure dictionaries in a clean, declarative way that can replace long chains of if/elif/else blocks.

def process_event(event):
    match event:
        case {'type': 'click', 'x': x, 'y': y}:
            print(f"Clicked at ({x}, {y})")
        case {'type': 'key_press', 'key': key}:
            print(f"Key '{key}' pressed")
        case {'type': 'quit'}:
            print("Quitting...")
        case _:
            print("Unknown event")

process_event({'type': 'click', 'x': 100, 'y': 250, 'button': 'left'})
# Outputs: Clicked at (100, 250)

The pattern matching not only checks for the presence of keys like 'type' but also extracts their values into variables like x, y, and key. It ignores extra keys like 'button', making it flexible for matching subsets of data. This is far more readable and less error-prone than manually checking for keys with get() and nesting if statements.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

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