There's a popular piece of advice that circulates in Python tutorials: "Always use dict.get() instead of bracket notation." It sounds clean. It sounds safe. And it is profoundly wrong as a blanket rule.
The real answer — the one that separates developers who understand Python from those who memorize recipes — is that dict.get() and dict[] communicate different intentions, carry different semantic contracts, and sit on top of different internal mechanisms. Choosing between them is not a safety preference. It is a design decision. This article gives you the understanding to make that decision correctly every time.
What Actually Happens When You Access a Dictionary
Before comparing the two approaches, you need to understand what's going on beneath the surface.
Python dictionaries are implemented as hash tables. The Python Design and History FAQ explains that CPython uses resizable hash tables for dictionaries because they offer superior lookup performance compared to tree-based structures, while keeping the implementation straightforward.
When you write config["timeout"], Python computes the hash of the string "timeout", uses that hash to calculate an index into an internal array, and retrieves the value stored at that location. Under normal conditions, this happens in O(1) constant time — the same speed whether your dictionary has ten entries or ten million.
The .get() method follows the same hash table lookup path. There is no fundamentally different data structure being consulted. The difference is entirely about what happens after the lookup, when the key does or does not exist.
Bracket Notation: dict[key]
Bracket access calls the dictionary's __getitem__ method. If the key exists, the value is returned. If the key does not exist, Python raises a KeyError.
server = {"host": "10.0.0.1", "port": 8080}
print(server["host"]) # "10.0.0.1"
print(server["timeout"]) # KeyError: 'timeout'
That KeyError is not a flaw — it is a feature. It is Python telling you, loudly and immediately, that your assumption about the shape of your data was wrong. The official Python tutorial states this directly: subscripting a dictionary with a non-existent key raises a KeyError.
This connects to a broader Python design principle. The Zen of Python (PEP 20), written by Tim Peters and posted to the Python mailing list in June 1999, includes the principle: "Errors should never pass silently." A KeyError is the dictionary refusing to let an incorrect assumption pass silently.
When you write server["timeout"], you are asserting: "I expect this key to be present." If it isn't, you want to know about it — because something upstream has gone wrong.
The .get() Method: dict.get(key, default)
The .get() method also looks up a key in the dictionary. If the key exists, its value is returned — identical to bracket access. If the key does not exist, instead of raising an exception, .get() returns a default value. If you don't specify a default, it returns None.
server = {"host": "10.0.0.1", "port": 8080}
timeout = server.get("timeout", 30)
print(timeout) # 30
protocol = server.get("protocol")
print(protocol) # None
No exception. No crash. Just a quiet fallback.
This behavior is documented in the official Python documentation for mapping types: the .get() method returns the value for key if key is in the dictionary, else it returns the default.
When you write server.get("timeout", 30), you are asserting something fundamentally different from bracket access. You are saying: "This key may or may not be present, and I have a sensible plan for both cases."
The Semantic Contract: Intent Matters
This is where many tutorials fail you. They treat .get() as a strictly superior replacement for brackets, but that collapses an important distinction.
Consider two real scenarios.
Scenario 1: Parsing a user profile from your own database.
def format_greeting(user):
return f"Hello, {user['first_name']}!"
If first_name is missing from this dictionary, your database schema has a serious problem, or your query returned corrupt data, or someone changed the API contract without telling you. You want a KeyError here. Silently returning None would produce the greeting "Hello, None!" and the bug might not surface for days.
Scenario 2: Reading optional settings from a config file.
def get_retry_count(config):
return config.get("max_retries", 3)
Here, it's perfectly normal for max_retries to be absent. The user simply hasn't overridden the default. Using brackets would punish users for not specifying every possible setting. .get() with a default is exactly right.
Guido van Rossum, Python's creator, has argued that code is primarily written to communicate with other programmers, not just to instruct machines. — from a 2019 interview on the Dropbox blog
Using dict[] says: "This key must exist." Using dict.get() says: "This key might not exist, and that's fine." Replacing all bracket access with .get() doesn't make your code safer. It makes your code lie about its assumptions.
The None Trap
There is a subtle but critical pitfall with .get() that many tutorials skip entirely.
settings = {"debug": None, "verbose": True}
debug_mode = settings.get("debug", False)
print(debug_mode) # None, not False
The .get() method only returns the default when the key is absent from the dictionary — not when the value is None or any other falsy value. If the key exists with a value of None, that None is returned, not your specified default.
This distinction matters enormously when working with JSON data (where null becomes None), database results (where nullable columns produce None), and any API that uses None as a meaningful value.
The safe way to distinguish "key is absent" from "key is present but None" is either a sentinel object or an explicit membership check:
_MISSING = object()
value = settings.get("debug", _MISSING)
if value is _MISSING:
print("Key not found at all")
else:
print(f"Key found with value: {value}")
Or more simply:
if "debug" in settings:
value = settings["debug"] # Could be None, and that's fine
else:
value = False # Key is genuinely absent
How None Propagation Causes Real Bugs
The None trap isn't just a theoretical concern. It creates bugs that are genuinely difficult to track down because the failure occurs far from the line that introduced the problem. Consider a data processing pipeline:
# Step 1: Extract user data from an API response
user = api_response.get("user") # Returns None if key missing
# Step 2: Later, in a completely different function...
def send_welcome_email(user_data):
email = user_data.get("email", "noreply@example.com")
# If user_data is None, this crashes with:
# AttributeError: 'NoneType' object has no attribute 'get'
send_email(email, "Welcome!")
The error appears in send_welcome_email, but the actual bug is in Step 1 — the missing key "user" was silently swallowed by .get() and turned into None. If brackets had been used in Step 1, the KeyError would have fired immediately with a clear message about what was missing and where.
This is the core argument against blanket .get() usage: it doesn't eliminate errors, it relocates them. A KeyError at the source is almost always easier to debug than an AttributeError or TypeError three function calls downstream.
When debugging a TypeError or AttributeError involving None, look upstream for .get() calls that might have silently returned None instead of raising a KeyError. The root cause is often several function calls away from where the crash actually happens.
Performance: Does It Matter?
Bracket access (dict[key]) is slightly faster than .get() when the key exists. The reason is straightforward: .get() involves a Python-level method call (the interpreter must resolve the attribute lookup and perform a function call), while bracket access through __getitem__ compiles to a specialized subscript operation that CPython handles directly at the C level. You can verify this yourself with the dis module:
import dis
def bracket_access(d):
return d["key"]
def get_access(d):
return d.get("key")
dis.dis(bracket_access)
# LOAD_FAST, LOAD_CONST, BINARY_SUBSCR, RETURN_VALUE
dis.dis(get_access)
# LOAD_FAST, LOAD_ATTR (get), LOAD_CONST, CALL, RETURN_VALUE
#
# Note: exact opcode names vary by Python version, but the
# structural difference is consistent --- bracket access
# uses fewer, more specialized bytecodes.
"Slightly" means single-digit nanoseconds per call on modern hardware. You would need billions of lookups in a tight loop before this difference became measurable in wall-clock time. Choose based on semantics, not speed.
The story changes when the key is missing. If the key isn't found, bracket access raises a KeyError, and catching exceptions with try/except is significantly more expensive than the simple conditional return that .get() performs internally. If you're routinely looking up keys that might not exist, .get() is both cleaner and faster than wrapping brackets in exception handlers.
The try/except Trade-Off
A common alternative to .get() is wrapping bracket access in a try/except block. This is Python's "ask forgiveness, not permission" (EAFP) idiom, and it has its place — but understanding its performance implications matters.
# EAFP pattern
try:
value = config["timeout"]
except KeyError:
value = 30
# Equivalent .get() pattern
value = config.get("timeout", 30)
When the key exists, try/except is nearly as fast as bare bracket access because no exception is raised and the only overhead is setting up the exception handler. However, when the key is missing, raising and catching a KeyError involves creating an exception object, unwinding the call stack, and matching the exception type. This is orders of magnitude slower than .get()'s simple conditional return.
import timeit
d = {"a": 1}
# Key missing: try/except is slow
def try_except_miss():
try:
return d["b"]
except KeyError:
return 0
def get_miss():
return d.get("b", 0)
print(timeit.timeit(try_except_miss, number=1_000_000))
print(timeit.timeit(get_miss, number=1_000_000))
# get_miss() will be significantly faster than try_except_miss()
The rule of thumb: use try/except when you expect the key to exist in the vast majority of cases and the missing-key path is truly exceptional. Use .get() when missing keys are a normal, expected part of the data. This isn't just a style preference — it reflects a real performance asymmetry in CPython's implementation.
The Alternatives: setdefault(), defaultdict, and __missing__
Python doesn't stop at two ways to handle dictionary access. The language offers a spectrum of approaches, each designed for a different use case.
dict.setdefault(key, default)
This method behaves like .get() with a side effect: if the key is absent, it inserts the default value into the dictionary before returning it.
connections = {}
# Without setdefault
if "db" not in connections:
connections["db"] = []
connections["db"].append("conn_1")
# With setdefault --- one line does the same thing
connections.setdefault("db", []).append("conn_2")
The .setdefault() method is particularly useful in accumulation patterns where you're building up lists or sets as dictionary values. But because Python evaluates function arguments before the call, the default argument is constructed every time .setdefault() is called, even when the key already exists and the default is immediately discarded. For inexpensive defaults like empty lists, this overhead is negligible. For expensive objects, it can be wasteful.
collections.defaultdict
Introduced in Python 2.5 as an addition to the collections module, defaultdict is a dictionary subclass that provides automatic defaults for missing keys when accessed via bracket notation.
from collections import defaultdict
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}
Without defaultdict, that loop would require either a .get() call or a conditional check on every iteration:
word_count = {}
for word in words:
word_count[word] = word_count.get(word, 0) + 1
The defaultdict approach is cleaner for accumulation patterns, but it changes the behavior of all bracket lookups on the dictionary. If you accidentally access word_count["typo"], a new entry is silently created, which can introduce subtle bugs.
Even on a defaultdict, calling .get() on a missing key returns None without creating a new entry. Only bracket access triggers __missing__. This asymmetry catches even experienced developers off guard.
from collections import defaultdict
d = defaultdict(int)
print(d.get("missing")) # None --- does NOT create the key
print(d["missing"]) # 0 --- DOES create the key
print(dict(d)) # {'missing': 0}
Custom __missing__ Subclasses
You can implement __missing__ yourself for fully custom behavior:
class CaseInsensitiveDict(dict):
def __missing__(self, key):
# Try lowercase version before giving up
lower_key = key.lower()
if lower_key in self:
return self[lower_key]
raise KeyError(key)
headers = CaseInsensitiveDict({"content-type": "application/json"})
print(headers["Content-Type"]) # "application/json"
This is powerful, but the same caveat applies: __missing__ only fires from bracket access, not from .get().
The Overlooked Tool: ChainMap
While dictionary merging with | creates a new dictionary, collections.ChainMap (available since Python 3.3) creates a view across multiple dictionaries without copying any data. It searches each dictionary in order and returns the first match — making it ideal for layered configuration systems.
from collections import ChainMap
cli_args = {"timeout": 60}
env_vars = {"timeout": 45, "retries": 5}
defaults = {"timeout": 30, "retries": 3, "log_level": "INFO"}
config = ChainMap(cli_args, env_vars, defaults)
print(config["timeout"]) # 60 (from cli_args, highest priority)
print(config["retries"]) # 5 (from env_vars)
print(config["log_level"]) # "INFO" (from defaults)
This eliminates the .get()-vs-brackets question entirely for configuration. Every key is guaranteed to be found somewhere in the chain, so you can confidently use bracket access. And because ChainMap holds references rather than copies, changes to any underlying dictionary are reflected immediately.
ChainMap differs from dictionary merging in a crucial way: mutations (writes, deletes) only affect the first dictionary in the chain. This makes it safe for temporary overrides — you can modify the top-level dictionary without polluting your defaults. The Python documentation for ChainMap describes this as being useful for simulating nested scopes.
Django's template engine uses this same layered-lookup pattern internally. Its Context class maintains an ordered list of dictionaries and searches them in sequence, layering template variables on top of context processors on top of defaults — the same conceptual model as ChainMap, implemented independently.
Modern Python: Pattern Matching and the Walrus Operator
Python 3.8 and 3.10 introduced two features that expand the dictionary access toolkit in ways that few articles discuss.
The Walrus Operator (:=) with Dictionary Access
The walrus operator (PEP 572, Python 3.8+) lets you assign and test a value in a single expression, which is particularly useful with .get():
# Without walrus: redundant lookup or temporary variable
value = data.get("key")
if value is not None:
process(value)
# With walrus: single expression
if (value := data.get("key")) is not None:
process(value)
This is especially powerful in loops that process sequences of dictionaries:
records = [{"name": "Alice", "score": 95}, {"name": "Bob"}, {"name": "Carol", "score": 87}]
# Cleanly filter and process in one pass
high_scorers = [
record["name"]
for record in records
if (score := record.get("score")) is not None
and score > 90
]
Structural Pattern Matching (Python 3.10+)
The match/case statement (PEP 634) can destructure dictionaries directly, providing an entirely different approach to the "key might be missing" problem:
def handle_event(event: dict):
match event:
case {"type": "click", "x": x, "y": y}:
handle_click(x, y)
case {"type": "keypress", "key": key}:
handle_keypress(key)
case {"type": event_type}:
log_unknown_event(event_type)
case _:
raise ValueError("Invalid event: missing 'type' key")
Pattern matching gives you something that neither .get() nor brackets offer: the ability to test for the shape of a dictionary and extract values in a single operation. The dictionary doesn't need every key specified in the pattern — it just needs the ones you name. Extra keys are silently ignored, which makes this ideal for processing JSON payloads where the schema varies.
Type Checking: What mypy Sees
The choice between .get() and brackets has consequences beyond runtime. Static type checkers like mypy treat these operations differently, and understanding the difference can prevent a class of bugs before your code ever runs.
config: dict[str, int] = {"timeout": 30, "retries": 3}
# mypy infers: int (will raise KeyError if missing)
timeout = config["timeout"]
# mypy infers: int | None (because default is None)
timeout = config.get("timeout")
# mypy infers: int (because default is int)
timeout = config.get("timeout", 0)
When you call .get() without a default, mypy correctly infers the return type as int | None. This means every subsequent use of that variable must handle the None case, or mypy will flag it. Bracket access, by contrast, returns a clean int type because mypy assumes the key exists (and if it doesn't, a runtime KeyError is the appropriate response).
For TypedDict types, the distinction is even sharper:
from typing import TypedDict
class UserProfile(TypedDict):
name: str
email: str
def greet(user: UserProfile) -> str:
# mypy knows 'name' MUST exist: returns str
# Using .get() here would give str | None,
# forcing unnecessary None checks on a required key
return f"Hello, {user['name']}"
The type checker is encoding the same semantic contract in the type system: bracket access asserts the key exists, .get() acknowledges it might not.
The PEP Trail: How Python's Dict Evolved
Several Python Enhancement Proposals have shaped how dictionaries behave today.
PEP 20 — The Zen of Python (written 1999, formalized 2004). Tim Peters authored these guiding principles and posted them to the Python mailing list in June 1999. The PEP document itself was created on August 19, 2004. Two of its aphorisms are directly relevant here: "Errors should never pass silently" and "In the face of ambiguity, refuse the temptation to guess." These principles underpin why bracket access raises KeyError rather than returning None — the language refuses to guess what you meant when the key isn't there. (Source: PEP 20)
PEP 274 — Dict Comprehensions (proposed 2001, shipped 2008–2010). Originally proposed by Barry Warsaw, dict comprehensions were deferred for years before finally landing in Python 3.0 (December 2008) and Python 2.7 (July 2010). They gave developers a filtering tool analogous to list comprehensions. You can use them to build new dictionaries that exclude certain keys — a form of conditional access at creation time:
raw = {"name": "alice", "age": 30, "password": "secret", "email": "alice@example.com"}
safe = {k: v for k, v in raw.items() if k != "password"}
PEP 448 — Additional Unpacking Generalizations (2015). Accepted for Python 3.5, this PEP allowed dictionary unpacking with ** in more contexts, enabling patterns like merging dictionaries with defaults:
defaults = {"timeout": 30, "retries": 3, "verbose": False}
user_config = {"timeout": 60}
config = {**defaults, **user_config}
# {'timeout': 60, 'retries': 3, 'verbose': False}
This is a powerful alternative to calling .get() repeatedly. You define your defaults in one dictionary, overlay user-provided values, and the result is a clean merged dictionary where every expected key is guaranteed to exist. You can then safely use bracket access on config because the merging step ensures completeness.
PEP 572 — Assignment Expressions (2018). Accepted for Python 3.8, this PEP introduced the := walrus operator, which enables inline assignment during dictionary lookups. This eliminated a common annoyance where you needed to call .get() and then check the result in separate statements. (Source: PEP 572)
PEP 584 — Add Union Operators to dict (2019). Accepted for Python 3.9, this PEP introduced the | merge operator and |= update operator for dictionaries, formalizing the merge pattern above:
config = defaults | user_config
This single-line merge eliminates entire classes of .get() calls. If you're choosing between .get() and [] because keys might be missing, consider whether the real solution is to guarantee key presence through a merge or schema validation before you access anything.
PEP 589 — TypedDict (2019). Accepted for Python 3.8, TypedDict lets you declare exactly which keys a dictionary must have and what types their values must be for static type checking with tools like mypy. This is the type system's way of enforcing the same contract that bracket access enforces at runtime:
from typing import TypedDict
class ServerConfig(TypedDict):
host: str
port: int
timeout: int
When your data has a known, fixed schema, TypedDict plus bracket access gives you compile-time guarantees that .get() with defaults never can.
PEP 634 — Structural Pattern Matching (2020). Accepted for Python 3.10, this PEP introduced match/case statements that can destructure dictionaries inline, offering a fundamentally different approach to handling dictionaries with variable shapes. Rather than checking for individual keys, you describe the entire expected structure and let the pattern matcher do the work. (Source: PEP 634)
Decision Framework: When to Use Which
Use dict[key] when: you expect the key to exist and its absence would be a bug. You're working with data that has a known, fixed schema (database rows, validated API responses, configuration after merging with defaults). You want failures to surface immediately during development rather than propagating as subtle logic errors downstream.
Use dict.get(key, default) when: the key is genuinely optional. You have a meaningful default value for when the key is absent. You're processing external or user-supplied data where missing fields are normal, not exceptional. You're accumulating or counting: counts[word] = counts.get(word, 0) + 1.
Use dict.setdefault(key, default) when: you want the default to be stored in the dictionary for future lookups. You're building up collections as values (lists, sets) and want to initialize-and-access in one step.
Use collections.defaultdict when: every missing key should get the same type of default. You're doing heavy accumulation (counting, grouping, building adjacency lists). You understand that every bracket access to a missing key will permanently add that key to the dictionary.
Use collections.ChainMap when: you have layered data sources (CLI args, environment variables, config files, defaults) and want to search them in priority order without copying data. You need mutations to only affect one layer. You want to avoid the entire .get()-vs-brackets question by guaranteeing that every key exists somewhere in the chain.
Use dictionary merging (|, ** unpacking) when: you can guarantee key presence by merging defaults before access. You want to separate the "ensure keys exist" step from the "use values" step.
Use match/case (Python 3.10+) when: you need to branch on the shape of a dictionary, not just individual keys. You're processing JSON or event data where different shapes require different handling. You want to destructure and extract multiple values simultaneously.
The Patterns That Matter
Here are the patterns you'll actually encounter in production code, and how to handle each.
Counting occurrences:
# Good -- .get() with arithmetic
counts = {}
for item in data:
counts[item] = counts.get(item, 0) + 1
# Better for heavy counting
from collections import Counter
counts = Counter(data)
Nested dictionary access (the JSON problem):
# Fragile -- any missing level crashes
city = response["data"]["user"]["address"]["city"]
# Defensive -- chained .get() calls
city = response.get("data", {}).get("user", {}).get("address", {}).get("city", "Unknown")
That chained .get() pattern is functional but grows unwieldy. For deeply nested data, consider dedicated libraries or restructuring your code to validate the shape of the data first.
Structural pattern matching for nested access (Python 3.10+):
# Clean -- pattern matching destructures in one step
match response:
case {"data": {"user": {"address": {"city": city}}}}:
print(f"City: {city}")
case _:
print("City: Unknown")
This is arguably the cleanest solution to the nested dictionary problem. The pattern describes the expected shape, and if the data doesn't match, the case _ branch handles it. No chained .get() calls, no risk of None propagation.
Configuration with defaults:
# Best pattern -- merge then use brackets with confidence
DEFAULTS = {"timeout": 30, "retries": 3, "log_level": "INFO"}
def connect(user_config):
config = DEFAULTS | user_config
timeout = config["timeout"] # Safe -- guaranteed to exist
retries = config["retries"] # Safe
Layered configuration with ChainMap:
from collections import ChainMap
import os
cli = parse_cli_args()
env = {k[4:].lower(): v for k, v in os.environ.items() if k.startswith("APP_")}
file_config = load_config_file("app.toml")
defaults = {"timeout": 30, "retries": 3, "log_level": "INFO"}
config = ChainMap(cli, env, file_config, defaults)
# Every key is guaranteed to resolve somewhere in the chain
# Safe to use brackets everywhere
The merge-then-bracket pattern and the ChainMap pattern both solve the same fundamental problem: they separate the concern of guaranteeing key presence from the concern of using values. Once you've guaranteed that every key exists — through merging, chaining, or schema validation — you can use bracket access confidently everywhere. The .get()-vs-brackets debate often dissolves when you ask the better question: "How do I guarantee this key exists before I need it?"
The .get() method and bracket notation are not interchangeable. They express different relationships between your code and its data. Bracket access asserts that a key must exist and treats its absence as an error worth surfacing immediately. The .get() method anticipates that a key might be absent and provides a graceful fallback.
Neither is inherently "safer" or "better." The Python language deliberately provides both — along with setdefault, defaultdict, ChainMap, TypedDict, pattern matching, and the walrus operator — because different situations call for different semantic contracts. The next time you reach for .get() out of habit, stop and ask: "Do I expect this key to be missing?" If the answer is no, use brackets. Let Python tell you when your assumptions are wrong. That KeyError might be the most helpful thing your code does all day.