`get()` returns default value instead of value matching to valid key when default value is an `error()`

Hello everyone!

Looking at the MWE below:

It appears error/throw are run before get is finished, causing get to be interrupted and the default value to be returned, rather than the value which matches the valid key as expected.

Is this the intended behaviour for get in this circumstance?

I think it is rarely necessary to define an error() as the default value for get() - I stumbled upon this purely by accident this afternoon.

To my understanding there are (several) other ways to include this function that avoid this particular behaviour.

I wanted to post this here in-case anyone else comes across the same thing in the future and wonders (as I did) why passing a valid key to their Dict() did not returning the matching stored value, rather the error() set as the default value.

Thanks and have a nice day!


MWE


d = Dict("a" => 1, "b" =>2)
k = "a"
err_msg = "Key '$k' not present in dictionary: $d"
@assert haskey(d, k) err_msg

# While k = "a", the assertions hold, else 'err_msg' is returned
@assert get(d, k, 0) == 1 err_msg
@assert get(d, k, "Default value") == 1 err_msg
@assert get(d, k, ArgumentError("No matching key found")) == 1 err_msg

# While k = "a", the error including the string "Default value" is returned, before the assertion test is completed
@assert get(d, k, error("Default value")) == 1 err_msg
@assert get(d, k, throw(ArgumentError("Default value"))) == 1 err_msg

Related discussion (discovered automatically by discourse)


Output from versioninfo() (incase relevant)

julia> versioninfo()
Julia Version 1.12.6
Commit 15346901f00 (2026-04-09 19:20 UTC)
Build Info:
  Official https://julialang.org release
Platform Info:
  OS: macOS (arm64-apple-darwin24.0.0)
  CPU: 8 Ɨ Apple M1
  WORD_SIZE: 64
  LLVM: libLLVM-18.1.7 (ORCJIT, apple-m1)
  GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 4 virtual cores)
Environment:
  JULIA_EDITOR = code
  JULIA_VSCODE_REPL = 1

yes, this is intended behavior and has nothing to do with get

ArgumentError is an object like anything else, something like (paraphrasing)

struct ArgumentError
    msg::String
end

so you can use it as a value, pass it around, assign to variables, etc.

but throw and error are not values. they are routines with side effects where the side effect is to raise some kind of error. see the difference:

julia> struct MyError x end

julia> MyError(123)
MyError(123)

julia> throw(MyError(123))
ERROR: MyError(123)

so throw is basically the primitive that does the error-throwing part of any object you pass it. and then error is more like a convenience function. you can think of error as (again, paraphrasing) something like error(msg) = throw(ErrorException(msg))

the reason you’re seeing this in the get call is that all function arguments are evaluated before running the function body. so the value you pass into default for get is not lazily evaluated.

you can see similar behavior like this

julia> get(Dict('a'=>1), 'a', println("hello, world!"))
hello, world!
1

julia> get(Dict('a'=>1), 'b', println("hello, world!"))
hello, world!

where the default value is in fact nothing, since this is what println actually evaluates to, but we still have the side effect (in this case printing to stdout).

to salvage the behavior you want, there is another get signature

get(f::Union{Function, Type}, collection, key)

  Return the value stored for the given key, or if no mapping for the key is present, return f(). Use get! to also store the default value in the dictionary.

where you could do

julia> get(Dict('a'=>1), 'b') do
           error("not found")
       end
ERROR: not found
Stacktrace:
 [1] error(s::String)
   @ Base error.jl:56
 [2] (::var"#5#6")()
   @ Main REPL[12]:2
 [3] get(default::var"#5#6", h::Dict{Char, Int64}, key::Char)
   @ Base dict.jl:528
 [4] top-level scope
   @ REPL[12]:1

Seems that get is not lazy, it evaluates the expression given as default even if the key is found:

julia> d   = Dict("a" => 1, "b" =>2)
Dict{String, Int64} with 2 entries:
  "b" => 2
  "a" => 1
julia> obj = [1]
1-element Vector{Int64}:
 1
julia> get(d, "a", begin obj[1] = 10 end)
1
julia> obj[1]
10

The version with a function as first element is instead lazy:

julia> obj[1] = 1
1
julia> get(d, "a") do
           obj[1] = 10
       end
1
julia> obj[1]
1
julia> get(d, "z") do
           obj[1] = 10
       end
10
julia> obj[1]
10

Thank you for the detailed explanations for why the observed behaviour happens and pointing out the alternative syntax get(d, k) do f() which causes the defined ā€˜default value’ to be evaluated after the attempt to find the key in the dict is complete!

Thank you as well for showing the difference between the two syntaxes for get()!

I’m now much more likely to check whether lazy evaluation is used for a function or not, or, as in this case, if the user can choose between two implementations for laziness if they wish.

This isn’t related to get not being lazy. In Julia, begin...end is just a code block, and as such is executed as soon as it is reached.

yes, but it seems that in the first version get(iterator,key,default) whatever is ā€œdefaultā€ is reached before the evaluation of checking for the key, while with the get(f,iterator,key) this is done before..

That’s just how Julia parses and executes anything. Whatever you put as an argument is evaluated fully before the outer function is called with that argument.