Python: Dictionaries, CRUD, Methods, and When to Use

A complete Python dictionary tutorial covering creation, CRUD (Create, Read, Update, Delete) operations, methods, iteration, nesting, and the hash table model behind it. Every example is tested on Python 3.14.6 and shows real output, so you can trust what you see.

“Dictionaries are the building blocks of Python. Classes are dictionaries. Modules are dictionaries. Namespaces are dictionaries. It’s dictionaries all the way down.”

Raymond Hettinger

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 18 minutes

Think of your phone’s contacts app. You never scroll to “the 47th number I saved”. You type a friend’s name, say “Rahul”, and the phone hands you his number straight away. A Python dictionary works exactly like that. You look things up by a meaningful key (a name, a username, a country code), not by a position number. With a list you would have to remember that “Rahul’s score is at index 0”. With a dictionary you just say scores["Rahul"] and get the answer instantly.

That is all a dictionary is: a collection of key-value pairs where each key maps to one value. Looking up a value by its key is fast no matter how big the dictionary gets (O(1) on average, so the speed stays flat). It is also the most important data structure in Python, because the language itself runs on dictionaries. Every module, every class, and every namespace is backed by a dict under the hood.

Creating a Python Dictionary

Hash Table (internal array)lands atKey‘Rahul’hash8743921065% table_sizeindex 30 empty1 Viraj: 922 empty3 Rahul: 954 Niranjan: 885 emptyKeys must behashableimmutable typesO 1 averagelookup timeInsertion orderpreserved 3.7+Python Dictionaries: How a Key Hashes to a Bucket for O(1) Lookup

Here is what happens inside a dictionary when you look something up. Python takes your key, say "Rahul", and runs it through a hash function. That gives a big number, which Python then folds down to a slot number in an internal array of buckets. Your value lands in that bucket. It works like a cloakroom counter: the attendant reads your token number and walks straight to the one shelf that holds your bag, no searching through the others.

Next time you ask for "Rahul", Python runs the same hash, lands on the same bucket, and grabs the value directly. It never walks through the other keys one by one, which is why lookups stay fast (O(1) on average) even with millions of entries. This is also why dictionary keys have to be hashable: they need to be immutable types like strings, numbers, or tuples, because the hash has to stay the same every time.

Let us start with the simplest thing you can do: make a dictionary. For the examples below, imagine three students named Rahul, Niranjan, and Viraj whose exam scores we want to track. There are a few ways to build a dictionary, and you will see all of them in real code, so it helps to recognise each one.

📄 create_dict.py: four ways to create a dictionary

# Literal syntax (most common)
scores = {"Rahul": 95, "Niranjan": 88, "Viraj": 92}

# dict() constructor with keyword arguments
config = dict(host="localhost", port=5432, debug=True)

# From a list of tuples
pairs = [("name", "Pravin"), ("age", 27), ("city", "Mumbai")]
profile = dict(pairs)

# Empty dict
empty = {}

print(f"Scores: {scores}")
print(f"Config: {config}")
print(f"Profile: {profile}")
print(f"Type: {type(scores)}")
print(f"Length: {len(scores)}")

▶ Output

Scores: {'Rahul': 95, 'Niranjan': 88, 'Viraj': 92}
Config: {'host': 'localhost', 'port': 5432, 'debug': True}
Profile: {'name': 'Pravin', 'age': 27, 'city': 'Mumbai'}
Type: <class 'dict'>
Length: 3

What happened here: The curly-brace form ({"Rahul": 95}) is what you will write 90 percent of the time. The dict() constructor is handy when your keys are simple words, because you pass them as keyword arguments with no quotes. Feeding dict() a list of (key, value) tuples is common when you build the pairs first and turn them into a dictionary later. And {} gives you an empty dictionary to fill in as you go. Notice that type() reports dict and len() counts the pairs, not the keys and values separately.

Reading Values: Access and Lookup

📄 reading.py: bracket access vs .get()

scores = {"Rahul": 95, "Niranjan": 88, "Viraj": 92}

# Bracket access: raises KeyError if the key is missing
print(f"Rahul: {scores['Rahul']}")

# .get(): returns None (or your default) if the key is missing
print(f"Anvi: {scores.get('Anvi')}")
print(f"Anvi: {scores.get('Anvi', 0)}")

# Check existence first
if "Viraj" in scores:
    print(f"Viraj: {scores['Viraj']}")

# KeyError example
try:
    print(scores["Anvay"])
except KeyError as e:
    print(f"KeyError: {e}")

▶ Output

Rahul: 95
Anvi: None
Anvi: 0
Viraj: 92
KeyError: 'Anvay'

What happened here: Bracket access (d[key]) is direct, but it raises a KeyError and stops your program the moment a key is missing. Think of it like asking a receptionist for a visitor named Anvay when no Anvay works there: bracket access throws its hands up, while .get() calmly says “nobody by that name” and hands you None (or whatever default you pass as the second argument). Reach for brackets when you are sure the key is there, and reach for .get() when it might not be. The in check is the third option: it just tells you yes or no before you try to read the value.

Adding and Updating

Adding a new pair to a Python dictionary and changing an existing one use the exact same syntax: d[key] = value. If the key is new, Python adds it. If the key is already there, Python overwrites the old value. It works like a notice board with name slips: writing a slip for a new name pins it up, writing one for an existing name replaces the old slip. There is no separate “add” and “edit” method to remember. In the example below we track a small dev team, and a new engineer named Aditi joins mid-way.

📄 crud.py: add, update, and bulk-update a dictionary

team = {"Rahul": "Backend", "Viraj": "Frontend"}

# Add a new key
team["Aditi"] = "DevOps"
print(f"After add: {team}")

# Update an existing key
team["Viraj"] = "Full Stack"
print(f"After update: {team}")

# Bulk update with .update()
team.update({"Niranjan": "ML", "Pravin": "Mobile"})
print(f"After bulk: {team}")

# setdefault: add ONLY if the key does not already exist
team.setdefault("Rahul", "Unknown")     # Rahul exists, so no change
team.setdefault("Vinay", "QA")          # Vinay is missing, so this adds him
print(f"After setdefault: {team}")

▶ Output

After add: {'Rahul': 'Backend', 'Viraj': 'Frontend', 'Aditi': 'DevOps'}
After update: {'Rahul': 'Backend', 'Viraj': 'Full Stack', 'Aditi': 'DevOps'}
After bulk: {'Rahul': 'Backend', 'Viraj': 'Full Stack', 'Aditi': 'DevOps', 'Niranjan': 'ML', 'Pravin': 'Mobile'}
After setdefault: {'Rahul': 'Backend', 'Viraj': 'Full Stack', 'Aditi': 'DevOps', 'Niranjan': 'ML', 'Pravin': 'Mobile', 'Vinay': 'QA'}

What happened here: The first two lines show the one-syntax rule in action. Setting team["Aditi"] on a missing key added her, and setting team["Viraj"] on an existing key overwrote “Frontend” with “Full Stack”. The .update() method is just a bulk version of the same thing: it adds or overwrites several pairs at once. The interesting one is setdefault(). It only writes if the key is missing, so the Rahul call did nothing (he was already there) while the Vinay call added him. Also notice the new keys land at the end. Dictionaries remember the order you inserted things, which is handy when order matters.

Removing Items

Python gives you a few ways to take pairs out, and they differ in one small but important way: whether they hand the removed value back to you, and whether they complain when the key is missing. Pick the one that matches what you need.

📄 removing.py: del, pop, popitem, and clear

scores = {"Rahul": 95, "Niranjan": 88, "Viraj": 92, "Pravin": 84}

# del: remove by key (raises KeyError if the key is missing)
del scores["Pravin"]
print(f"After del: {scores}")

# pop: remove the key and give back its value
removed = scores.pop("Niranjan")
print(f"Popped: {removed}, Remaining: {scores}")

# pop with a default: no error when the key is missing
missing = scores.pop("Anvi", "not found")
print(f"Missing pop: {missing}")

# popitem: remove and return the last inserted pair
last = scores.popitem()
print(f"Last item: {last}, Remaining: {scores}")

# clear: empty the dictionary
scores.clear()
print(f"After clear: {scores}")

▶ Output

After del: {'Rahul': 95, 'Niranjan': 88, 'Viraj': 92}
Popped: 88, Remaining: {'Rahul': 95, 'Viraj': 92}
Missing pop: not found
Last item: ('Viraj', 92), Remaining: {'Rahul': 95}
After clear: {}

What happened here: Use del when you just want a key gone and you do not care about the value. Use pop() when you want the value back, like pulling a sticky note off a board and reading it before you throw it out. The default form, pop("Anvi", "not found"), is the safe one: it returns your fallback instead of crashing when the key is missing. popitem() removes the last pair you added (handy for processing a dictionary like a stack), and clear() wipes everything and leaves you with an empty {}.

Iterating Over Dictionaries

When you loop over a Python dictionary, you get the keys by default. Think of a teacher taking attendance: reading the register top to bottom calls out only the names (the keys), but the teacher can also glance across the row to see each student’s marks (the values). Most of the time you want the name and the marks together, and that is what .items() is for. Here are the three ways to walk through a dictionary, from least to most useful.

📄 iterating.py: keys, values, and items

team = {"Rahul": 95, "Niranjan": 88, "Viraj": 92}

# Looping over a dictionary gives you the keys by default
print("Keys:")
for name in team:
    print(f"  {name}")

# .values() when you only need the values
print(f"\nAll scores: {list(team.values())}")

# .items() gives you both key and value, the one you will use most
print("\nAll items:")
for name, score in team.items():
    print(f"  {name}: {score}")

# Common pattern: find the highest score
best = max(team.items(), key=lambda item: item[1])
print(f"\nBest: {best[0]} with {best[1]}")

▶ Output

Keys:
  Rahul
  Niranjan
  Viraj

All scores: [95, 88, 92]

All items:
  Rahul: 95
  Niranjan: 88
  Viraj: 92

Best: Rahul with 95

What happened here: Looping straight over team gave us the names (the keys), which is why the first block printed Rahul, Niranjan, and Viraj with no scores. The .items() loop unpacks each pair into two variables at once, name and score, so you get both sides without a second lookup. That last line is a pattern you will copy a lot: max(team.items(), key=lambda item: item[1]) finds the pair with the biggest value by telling max to compare on item[1] (the score) rather than the name.

Useful Dict Methods

A handful of methods come up again and again. Here are the ones worth memorising: copying a dictionary without sharing it, building one with a default value, and merging two dictionaries into one.

📄 methods.py: copy, fromkeys, and the merge operator

original = {"a": 1, "b": 2, "c": 3}

# Shallow copy: a separate dictionary, not just a second label
copy = original.copy()
copy["d"] = 4
print(f"Original: {original}")
print(f"Copy: {copy}")

# fromkeys: build a dictionary with the same starting value
players = ["Rahul", "Viraj", "Aviraj"]
scores = dict.fromkeys(players, 0)
print(f"Initial scores: {scores}")

# Merge with the | operator (Python 3.9+)
defaults = {"theme": "dark", "lang": "en"}
user_prefs = {"lang": "hi", "font": 14}
merged = defaults | user_prefs
print(f"Merged: {merged}")

▶ Output

Original: {'a': 1, 'b': 2, 'c': 3}
Copy: {'a': 1, 'b': 2, 'c': 3, 'd': 4}
Initial scores: {'Rahul': 0, 'Viraj': 0, 'Aviraj': 0}
Merged: {'theme': 'dark', 'lang': 'hi', 'font': 14}

What happened here: .copy() made a real second dictionary, so adding "d" to the copy left the original untouched. (More on the difference between copying and just adding a second label in the catch below.) dict.fromkeys(players, 0) is a quick way to start every player at zero, like handing out fresh scorecards. The | merge operator (Python 3.9+) builds a new dictionary from two others, and when a key appears in both, the right-hand side wins. That makes it the cleanest way to layer user choices on top of default settings: defaults | user_prefs means “start with the defaults, then let the user override anything they set”.

Nested Dictionaries

A value in a dictionary can be anything, including another dictionary. This is how you model real records: each person has a name (the key) and a little profile (a dictionary of their own). It is the same shape you see in JSON (JavaScript Object Notation) from an API (Application Programming Interface), so getting comfortable with it now pays off everywhere later.

📄 nested.py: dictionaries inside a dictionary

team = {
    "Rahul": {"age": 28, "role": "Backend", "scores": [95, 92, 88]},
    "Viraj": {"age": 26, "role": "Frontend", "scores": [88, 90, 85]},
    "Niranjan": {"age": 30, "role": "ML", "scores": [91, 94, 89]},
}

# Access nested values
print(f"Rahul's role: {team['Rahul']['role']}")
print(f"Viraj's avg: {sum(team['Viraj']['scores']) / len(team['Viraj']['scores']):.1f}")

# Iterate nested
for name, info in team.items():
    avg = sum(info["scores"]) / len(info["scores"])
    print(f"{name} ({info['role']}): avg {avg:.1f}")

▶ Output

Rahul's role: Backend
Viraj's avg: 87.7
Rahul (Backend): avg 91.7
Viraj (Frontend): avg 87.7
Niranjan (ML): avg 91.3

What happened here: To reach a nested value you just chain the keys: team['Rahul']['role'] reads “in team, find Rahul, then in his profile find role”. Think of it like a building directory. The outer dictionary is the floor list, and each inner dictionary is the room directory for that floor. The loop does the same thing for everyone at once: for name, info in team.items() hands you each person and their profile, and we average their scores list right there. Watch out for one thing, though. The chain only works if every key in it exists; a typo anywhere along the way raises a KeyError.

The Catch: Mutable Default Arguments

📄 catch.py: never use a dict as a default argument

# BAD: the default dict is created once and shared across all calls
def add_item_bad(name, bag={}):
    bag[name] = True
    return bag

r1 = add_item_bad("apple")
r2 = add_item_bad("banana")
print(f"r1: {r1}")  # Both have apple AND banana!
print(f"r2: {r2}")
print(f"Same object: {r1 is r2}")

# GOOD: use None as the default and make a fresh dict inside
def add_item_good(name, bag=None):
    if bag is None:
        bag = {}
    bag[name] = True
    return bag

r3 = add_item_good("apple")
r4 = add_item_good("banana")
print(f"\nr3: {r3}")
print(f"r4: {r4}")

▶ Output

r1: {'apple': True, 'banana': True}
r2: {'apple': True, 'banana': True}
Same object: True

r3: {'apple': True}
r4: {'banana': True}

What happened here: This one surprises almost everyone the first time. Python builds the default value once, when it reads the def line, not fresh on every call. So that {} in add_item_bad is a single dictionary that every default call quietly shares. It is like a hotel handing every new guest the same notebook instead of a fresh one: whatever the last guest scribbled is still in there. Add “apple” on the first call and “banana” on the second, and both come back holding both items, because they are literally the same object (which is why r1 is r2 printed True).

The fix is the standard one: default to None, then create a brand new {} inside the function. Now each call gets its own dictionary, and r3 and r4 stay separate. Rule of thumb: never put a mutable value (a dict, a list, a set) directly in a default argument.

Common Mistakes

Mistake 1: Accessing a missing key with brackets

🚫 KeyError crash

d = {"a": 1}
print(d["b"])  # KeyError: 'b'

✅ Use .get() for safe access

d = {"a": 1}
print(d.get("b", "default"))  # "default"

Mistake 2: Modifying a dict while iterating

🚫 RuntimeError: deleting while looping

d = {"a": 1, "b": 2, "c": 3}
for key in d:
    if d[key] < 2:
        del d[key]   # changing the dict mid-loop

▶ Output

Traceback (most recent call last):
  File "mistake.py", line 2, in <module>
    for key in d:
               ^
RuntimeError: dictionary changed size during iteration

✅ Loop over a copy of the keys first

d = {"a": 1, "b": 2, "c": 3}
for key in list(d.keys()):
    if d[key] < 2:
        del d[key]
print(d)  # {'b': 2, 'c': 3}

What happened here: Deleting a key while you are still looping over the dictionary is like tearing pages out of a book while you are reading it. Python loses its place and raises RuntimeError: dictionary changed size during iteration. Wrapping the keys in list(d.keys()) makes a snapshot of the keys before the loop starts, so you can safely delete from the original dictionary while you walk the copy.

When You Will Use This

The Python dictionary is not a textbook curiosity. You reach for one constantly once you start building real things. Here are three places dictionaries show up almost every day:

  • Reading JSON from an API. When you call a web service with the requests library and run response.json(), what you get back is a dictionary (often with dictionaries nested inside it). Pulling out data["user"]["email"] is exactly the nested-key access you saw above.
  • Counting things. Word counts, votes, how many orders each customer placed: the pattern is “use the thing as a key, keep a running total as the value”. counts[word] = counts.get(word, 0) + 1 is one of the most common lines in real Python.
  • Configuration and settings. App settings, default options, and user preferences all map cleanly to key-value pairs. The defaults | user_prefs merge you saw is the standard way to let users override only the settings they care about.

Best Practices

  • DO use .get(key, default) when the key might not exist
  • DO use .items() when you need both key and value in a loop
  • DO use the | merge operator (Python 3.9+) for merging dicts
  • DO use None as default for mutable arguments, not {}
  • DON’T modify a dict while iterating over it. Copy the keys first with list(d.keys())
  • DON’T use mutable objects (lists, dicts) as dictionary keys

Conclusion

So that is the Python dictionary: a mutable, ordered (since Python 3.7) collection of key-value pairs with fast O(1) lookup. Keys have to be hashable, which in practice means strings, numbers, or tuples. Reach for .get() when a key might be missing, .items() when you loop, and | when you merge. Once you start noticing them, you will see dictionaries everywhere: configuration files, JSON from APIs, database rows, and the namespaces of the language itself all map naturally onto this one shape. Learn the dictionary well and a huge chunk of real Python suddenly reads easily.

Next up: Dictionary Comprehensions and Nested Dictionaries, where you will learn the one-line way to build dictionaries from scratch. And if you want the full roadmap, from first script to AI/ML projects, browse the Python + AI/ML tutorial series home for every post in order.

Practice Exercises

  1. Exercise 1: Create a 5-country capitals dictionary. Look up by country.
  2. Exercise 2: Build a word frequency counter.
  3. Exercise 3: Build a phone book with add, search, delete, list operations.

Frequently Asked Questions

What is a dictionary in Python?

A Python dictionary is a mutable, ordered (Python 3.7+) collection of key-value pairs. Created with {key: value} syntax. Keys must be unique and hashable. Values can be any type. Lookup by key is O(1) average time.

Are Python dictionaries ordered?

Yes, since Python 3.7 (officially guaranteed). Dictionaries maintain insertion order, so iterating over a dict yields keys in the order they were added. Before 3.7, order was an implementation detail, not a guarantee.

What is the difference between dict[key] and dict.get(key)?

dict[key] raises KeyError if the key doesn’t exist. dict.get(key) returns None (or a specified default) instead. Use brackets when you’re sure the key exists; use .get() when it might not.

Can a Python dictionary have duplicate keys?

No. Each key must be unique. If you assign a value to an existing key, it overwrites the old value. {'a': 1, 'a': 2} results in {'a': 2}.

What types can be dictionary keys?

Only hashable (immutable) types: strings, numbers, tuples, frozensets, and booleans. Lists, dicts, and sets cannot be keys because they’re mutable and unhashable. Custom objects can be keys if they implement __hash__ and __eq__.

How do I merge two dictionaries in Python?

Use the | operator (Python 3.9+): merged = d1 | d2. Or use d1.update(d2) to merge in place. Or use unpacking: {**d1, **d2}. In all cases, right-hand values win on key conflicts.

Interview Questions on Python Dictionaries

How interviewers actually probe this topic: real scenarios, with answers you can say out loud.

Q: Your code reads fields from an API response dict and works fine in testing, but in production it sometimes crashes with a KeyError. What is going on and how do you fix it?

The API is occasionally leaving out an optional field, and bracket access (data["field"]) raises KeyError the moment that happens. Switch the optional fields to data.get("field", default) so a missing key returns a sensible fallback instead of crashing, or check with if "field" in data: first. Keep bracket access only for fields the API guarantees, because a loud failure there is actually useful for catching real bugs.

Q: Why is looking up a value in a dictionary fast even when the dictionary has millions of entries?

Because a dict is a hash table. Python runs the key through a hash function, which points directly to a bucket in an internal array, so there is no walking through the other entries one by one. That makes lookup O(1) on average, meaning the time stays roughly flat as the dictionary grows. This is also why keys must be hashable: the hash of a key has to stay the same for the lifetime of the entry.

Q: You loop over a dictionary and delete entries that fail a check, and Python raises RuntimeError: dictionary changed size during iteration. What do you do?

Python refuses to let you resize a dict while you are iterating over it, because the iterator would lose its place. The simple fix is to iterate over a snapshot of the keys: for key in list(d.keys()): copies the keys into a list first, so deleting from the original dict is safe. Alternatively, build a new dict with only the entries you want to keep and replace the old one.

Q: What is the difference between dict.get(key, default) and dict.setdefault(key, default)?

Both return the value if the key exists and the default if it does not, but setdefault() also writes the default into the dictionary when the key is missing, while get() never modifies anything. So get() is a pure read, and setdefault() is a read-or-insert. Use setdefault() for patterns like grouping items into lists, where you want the key created on first sight.

Q: A teammate wrote def fetch(url, cache={}) to memoise results, and now the function returns stale data across unrelated calls. Why?

Default argument values are evaluated once, when Python reads the def line, so that {} is a single dictionary shared by every call that does not pass its own cache. Entries added in one call are still there in the next, which is exactly the stale data being observed. The standard fix is cache=None plus if cache is None: cache = {} inside the function, or a proper module-level cache if sharing is actually intended.

Q: You call config.copy(), change a nested value in the copy, and the original config changes too. Why, and what is the fix?

.copy() makes a shallow copy: it creates a new outer dictionary, but the values inside are the same objects, so a nested dict is shared between the original and the copy. Mutating that shared inner dict shows up in both. When you need fully independent nested data, use copy.deepcopy(config) from the copy module, which recursively copies every level.

Want more? the official Python documentation documents everything this post could not fit.

Previous: Python: Tuples, Immutability, Packing, Unpacking, Named

Next: Python: Dictionary Comprehensions & Nested Dictionaries

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

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