PSA: sometimes keywords affect dispatch

Did you know that keywords can actually affect method dispatch in Julia?

The Julia docs say they don’t:

Keyword arguments behave quite differently from ordinary positional arguments. In particular, they do not participate in method dispatch. Methods are dispatched based only on positional arguments, with keyword arguments processed after the matching method is identified.

But, actually, there is an 11-year-old correctness bug documenting a situation where keywords do affect dispatch. And this situation is much more common than you would think.

This bit me recently, and I think it’s quietly biting other people too. After pulling out a lot of my hair, I learned a lot about Julia’s dispatch system. Basically, there are effectively two method tables - one for positional-only methods, and one for anything with keywords. However, there is an issue here: if you call a function using any keywords, it will immediately switch to the keyword table, and only search there. That means a more specific positional-only method will not be matched, and this re-routing happens silently!

For example:

julia> process(x; verbose=false) = "generic"
process (generic function with 1 method)

julia> process(x::Vector) = "specialized"
process (generic function with 2 methods)

julia> process([1, 2, 3])
"specialized"

julia> process([1, 2, 3]; verbose=true)
"generic"

Adding verbose=true routes the call to the less-specific method and silently skips process(::Vector), searching the keyword table only. In other words, we see keywords affecting dispatch.

The same bug has a second symptom. Redefining a method without keywords doesn’t replace the keyword handling of an earlier definition with the same positional signature, even though Julia itself thinks there is only a single method:

julia> g(x; y=1) = "with keywords";

julia> g(x) = "without keywords";

julia> g
g (generic function with 1 method)

julia> g(1)
"without keywords"

julia> g(1; y=2)
"with keywords"

“1 method,” but two behaviors, depending on whether you pass a keyword. Spooky.

Under the hood, a keyword call f(args...; kw...) is lowered to Core.kwcall(kw_nt, f, args...), a separate generic function that holds the keyword table. A positional-only method never gets an entry there, which is why it becomes invisible the moment you pass a keyword.

The case that really seemed widespread is sizehint!. In Julia 1.11, Base added a shrink keyword to sizehint!, which had previously been positional-only. It seems this slipped past a lot of people. Julia internals now call sizehint!(d, n; shrink=true) in several places (dict copy, merge!, union!), and that shrink keyword sends the call through the keyword path. So every custom sizehint!(::MyType, n) that never declared a shrink keyword is invisible to it, and the call quietly falls back to the generic Base version, which usually allocates nothing and ignores your method entirely! So if you wrote a custom sizehint! for your type and didn’t know about the new shrink keyword, there’s a good chance Julia internals have been skipping it entirely. I suspect a lot of packages are hit by this one without realizing. (PkgEval turned up quite a few.)


So, what is the status? When I saw this, I couldn’t believe it was still a bug after 11 years, so I spent a lot of time trying to fix it in #60499. The idea is simple: whenever you define a positional-only method, also emit a matching stub into the keyword table with the same positional signature and zero keywords. So kwcall can no longer silently reroute to a less specific method; it throws an error instead.

The catch is it adds one extra method per positional definition, which slightly grows the sysimage, and the Julia team wanted a lower-overhead approach. A cleaner version rewriting dispatch would be a much larger project, and nobody has yet signed up for this. So for now this is just “what Julia does.” But at the very least, I think more people should know about it, hence this post.

The fix attempt also surfaced that this trips up Base itself. In several places a keyword got added to a previously keyword-less method, which then silently reroutes calls on more specific types to a generic fallback (the sizehint!/shrink case above is one). So it’s not just user packages; this is easy even for maintainers to forget when extending functions in Base. (I also opened a docs PR for good measure, #61886.)

We should be able to detect this, right? I.e. write a script that goes over the entire current method table and finds all these issues, i.e. all instances where f(x) and f(x;(;)...) get dispatched to different methods.

That would be very useful:

  1. People would be able to check whether they are affected by this questionable behavior
  2. This would probably detect a lot of real bugs, even more than test failures with your PR!
  3. This would be a very good linter rule.
  4. This also supplies political ammunition for your crusade to fix #9498

Don’t underestimate (4).

Yeah it would be nice to have an Aqua.jl check for this. I’m not sure how they would detect it though.

Maybe the simplest might be to repurpose that PR’s strategy of injecting 0-keyword methods into the Core.kwcall table for each positional-only signature. Then it will simply throw whenever you hit this bug.

I remember the first PkgEval of that PR was pretty gory, mostly due to the shrink keyword added in Julia 1.11 which resulted in tons of Julia packages erroring out.

Even now, this is still a pervasive issue in the Julia ecosystem - there are many specific sizehint! methods getting skipped in favor of the generic no-op one that doesn’t pre-allocate at all because some internal Julia Base code uses an explicit shrink keyword.