`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.

This is not specific to get, it’s just how function calls work: all of the arguments are evaluated and then passed to the function.

It’s not even specific to Julia: almost all programming languages execute function calls like this.

(The exception being special syntaxes like && that look like function/operator calls but are actually control flow.)

I think it’s instructive to look at languages where this is different. Take scala:

> def lazyCall(cond:Boolean, someBlock: => Any):Any = if(cond) someBlock else null
def lazyCall(cond: Boolean, someBlock: => Any): Any

> def eagerCall(cond:Boolean, item:Any):Any = if(cond) item else null
def eagerCall(cond: Boolean, item: Any): Any

> lazyCall(false, throw new RuntimeException("oops"))
val res0: Any = null

> eagerCall(false, throw new RuntimeException("oops"))
java.lang.RuntimeException: oops
  ... 39 elided

For scala, it depends on the callee declaration whether it accepts a zero-argument anonymous function (ā€œblockā€ argument), which necessarily creates a closure for lazy evaluation, or an eagerly evaluated value.

It is impossible to guess from reading the callsite code.

This is similar in spirit to C++: Functions can take ā€œreferenceā€ arguments (which really are pointers), and it is impossible to guess from reading the callsite code whether you’re passing a pointer or a value.

===========

This syntax is very pretty and convenient. But I think it is terrible design. I hate it. Code is being read more than it is written. Be explicit.

Thank you julia for not doing something terrible like that.

Luckily there is no risk of well-meaning people trying to introduce something like that to julia: This kind of bullshit only works if the callsite statically knows the callee declaration; and julia is a dynamic, not static language.

There are dynamic languages with lazy evaluation, like R, but then every argument is always lazily evaluated. Once, at the first use in the called function, in the callers environment.