Other answers have clearly explained the difference between dict bracket keying and .get
and mentioned a fairly innocuous pitfall when None
or the default value is also a valid key.
Given this information, it may be tempting conclude that .get
is somehow safer and better than bracket indexing and should always be used instead of bracket lookups, as argued in Stop Using Square Bracket Notation to Get a Dictionary's Value in Python, even in the common case when they expect the lookup to succeed (i.e. never raise a KeyError
).
The author of the blog post argues that .get
"safeguards your code":
Notice how trying to reference a term that doesn't exist causes a KeyError
. This can cause major headaches, especially when dealing with unpredictable business data.
While we could wrap our statement in a try
/except
or if
statement, this much care for a dictionary term will quickly pile up.
It's true that in the uncommon case for null (None
)-coalescing or otherwise filling in a missing value to handle unpredictable dynamic data, a judiciously-deployed .get
is a useful and Pythonic shorthand tool for ungainly if key in dct:
and try
/except
blocks that only exist to set default values when the key might be missing as part of the behavioral specification for the program.
However, replacing all bracket dict lookups, including those that you assert must succeed, with .get
is a different matter. This practice effectively downgrades a class of runtime errors that help reveal bugs into silent illegal state scenarios that tend to be harder to identify and debug.
A common mistake among programmers is to think exceptions cause headaches and attempt to suppress them, using techniques like wrapping code in try
... except: pass
blocks. They later realize the real headache is never seeing the breach of application logic at the point of failure and deploying a broken application. Better programming practice is to embrace assertions for all program invariants such as keys that must be in a dictionary.
The hierarchy of error safety is, broadly:
Error category |
Relative ease of debugging |
Compile-time error |
Easy; go to the line and fix the problem |
Runtime exception |
Medium; control needs to flow to the error and it may be due to unanticipated edge cases or hard-to-reproduce state like a race condition between threads, but at least we get a clear error message and stack trace when it does happen. |
Silent logical error |
Difficult; we may not even know it exists, and when we do, tracking down state that caused it can be very challenging due to lack of locality and potential for multiple assertion breaches. |
When programming language designers talk about program safety, a major goal is to surface, not suppress, genuine errors by promoting runtime errors to compile-time errors and promote silent logical errors to either runtime exceptions or (ideally) compile-time errors.
Python, by design as an interpreted language, relies heavily on runtime exceptions instead of compiler errors. Missing methods or properties, illegal type operations like 1 + "a"
and out of bounds or missing indices or keys raise by default.
Some languages like JS, Java, Rust and Go use the fallback behavior for their maps by default (and in many cases, don't provide a throw/raise alternative), but Python throws by default, along with other languages like C#. Perl/PHP issue an uninitialized value warning.
Indiscriminate application of .get
to all dict accesses, even those that aren't expected to fail and have no fallback for dealing with None
(or whatever default is used) running amok through the code, pretty much tosses away Python's runtime exception safety net for this class of errors, silencing or adding indirection to potential bugs.
Other supporting reasons to prefer bracket lookups (with the occasional, well-placed .get
where a default is expected):
- Prefer writing standard, idiomatic code using the tools provided by the language. Python programmers usually (correctly) prefer brackets for the exception safety reasons given above and because it's the default behavior for Python dicts.
- Always using
.get
forfeits intent by making cases when you expect to provide a default None
value indistinguishable from a lookup you assert must succeed.
- Testing increases in complexity in proportion to the new "legal" program paths permitted by
.get
. Effectively, each lookup is now a branch that can succeed or fail -- both cases must be tested to establish coverage, even if the default path is effectively unreachable by specification (ironically leading to additional if val is not None:
or try
for all future uses of the retrieved value; unnecessary and confusing for something that should never be None
in the first place).
.get
is a bit slower.
.get
is harder to type and uglier to read (compare Java's tacked-on-feel ArrayList
syntax to native-feel C# Lists
or C++ vector code). Minor.
Some languages like C++ and Ruby offer alternate methods (at
and fetch
, respectively) to opt-in to throwing an error on a bad access, while C# offers opt-in fallback value TryGetValue
similar to Python's get
.
Since JS, Java, Ruby, Go and Rust bake the fallback approach of .get
into all hash lookups by default, it can't be that bad, one might think. It's true that this isn't the largest issue facing language designers and there are plenty of use cases for the no-throw access version, so it's unsurprising that there's no consensus across languages.
But as I've argued, Python (along with C#) has done better than these languages by making the assert option the default. It's a loss of safety and expressivity to opt-out of using it to report contract violations at the point of failure by indiscriminately using .get
across the board.