A borrow checker for Julia
See the codeThis is an experimental package for emulating a runtime borrow checker in Julia, using a macro layer over regular code. This is built to mimic Rust's ownership, lifetime, and borrowing semantics. This tool is mainly to be used in development and testing to flag memory safety issues, and help you design safer code.
BorrowChecker.jl provides one layer:
Automatic checking (BorrowChecker.@safe)
In Julia, when you write x = [1, 2, 3], the actual object exists completely independently of the variable, and you can refer to it from as many variables as you want without issue:
x = [1, 2, 3]
y = x
println(length(x))
# 3
Once there are no more references to the object, the "garbage collector" will work to free the memory.
Rust is much different. For example, the equivalent code is invalid in Rust
let x = vec![1, 2, 3];
let y = x;
println!("{}", x.len());
// error[E0382]: borrow of moved value: `x`
Rust refuses to compile this code. Why? Because in Rust, objects (vec![1, 2, 3]) are owned by variables. When you write let y = x, the ownership of vec![1, 2, 3] is moved to y. Now x is no longer allowed to access it.
To fix this, we would either write
let y = x.clone();
// OR
let y = &x;
to either create a copy of the vector, or borrow x using the & operator to create a reference. You can create as many references as you want, but there can only be one original object.
This "ownership" paradigm can help improve safety of code. Especially in complex, multithreaded codebases, it is easy to shoot yourself in the foot and modify objects which are "owned" (editable) by something else. Rust's ownership and lifetime model makes it so that you can prove memory safety of code! Standard thread races are literally impossible. (Assuming you are not using unsafe { ... } to disable safety features, or the borrow checker itself has a bug, etc.)
In BorrowChecker.jl, we demonstrate an implementation of some of these ideas. The aim is to build a development layer that can help prevent a few classes of memory safety issues, without affecting runtime behavior of code.
BorrowChecker.@safeBorrowChecker.@safe automatically instruments a function by analyzing the compiler IR and runs a best-effort borrow check at runtime. This requires Julia 1.12 or 1.13 (on newer versions the checker falls back to warn-and-pass-through stubs until support lands).
[!WARNING] This macro is highly experimental and compiler-dependent. There are likely bugs and false positives. It is intended for development and testing, and does not guarantee memory safety.
@safe supports a few options that are compiled into a BorrowChecker.Config:
scope (default :function): whether to recursively borrow-check callees (:none, :function, :module, :user, :all).max_summary_depth (default 12): recursion depth limit for effect summarization when effects cannot be directly resolved.optimize_until (default varies): which compiler pass to stop at when fetching IR (Base.code_ircode_by_type).scope meanings:
:none: disable @safe entirely.:function: check only the annotated method.:module: recursively check callees defined in the module where @safe is used.:user: recursively check callees, but ignore Core and Base (including their submodules).:all: recursively check callees across all modules (very aggressive).The @safe checked-cache is keyed by specialization and these options, so checking a function once under scope=:function will not incorrectly skip a later recursive check under scope=:module / :all.
@safe is meant to be a drop-in tripwire for existing code:
This analyzes the compiler’s IR, so it can catch patterns that are "hidden" by lowering (keyword calls, closure captures, views, etc.). It is intentionally best-effort: when it cannot determine what a call does, it will be conservative (and may throw false positives). For regions where the checker is overly conservative, silence them with @unsafe blocks.
When you write:
BorrowChecker.@safe function f(args...)
# ...
end
the macro rewrites the function so that:
f(::Vector{Int})), and caches the result (so future calls are faster).BorrowCheckError with a source-level-ish diagnostic.BorrowChecker.jl's @safe macro can detect when values are modified through aliased bindings, and throw an error:
julia> import BorrowChecker
julia> BorrowChecker.@safe function f()
x = [1, 2, 3]
y = x
push!(x, 4)
return y
end
f (generic function with 1 method)
julia> f() # errors
This will generate a helpful error pointing out the location of the borrow check violation, and the statement that violated the rule:
ERROR: BorrowCheckError for specialization Tuple{typeof(f)}
method: f() @ Main REPL[7]:1
[1] stmt#7: cannot perform write: value is aliased by another live binding at REPL[7]:4
2 x = [1, 2, 3]
3 y = x
> 4 push!(x, 4)
5 return y
6 end
stmt: Main.push!(%5, 4)
To fix it, simply copy the value, which will avoid the error:
julia> BorrowChecker.@safe function f()
x = [1, 2, 3]
y = copy(x)
push!(x, 4)
return y
end
f (generic function with 1 method)
julia> f()
3-element Vector{Int64}:
1
2
3
Much like Rust's ownership model, BorrowChecker.jl's @safe macro attempts to infer when values escape their scope (moved/consumed) and throw an error if they are used afterwards.
julia> const CACHE = Dict()
Dict{Any, Any}()
julia> foo(x) = (CACHE[x] = 1; nothing)
foo (generic function with 1 method)
julia> BorrowChecker.@safe function bar()
x = [1, 2]
foo(x)
return x
end
bar (generic function with 1 method)
julia> bar() # errors
This generates the following error:
ERROR: BorrowCheckError for specialization Tuple{typeof(bar)}
method: bar() @ Main REPL[13]:1
[1] stmt#6: value escapes/consumed by unknown call; it (or an alias) is used later at REPL[13]:3
1 BorrowChecker.@safe function bar()
2 x = [1, 2]
> 3 foo(x)
4 return x
5 end
stmt: Main.foo(%5)
Why is this an error? Because x was stored as a key in the cache, but is mutable externally. Furthermore, it is returned by bar! This is a violation of borrowing rules. Once the value gets stored in the cache, its ownership is transferred to the cache, and is no longer accessible by bar. So this example is illegal.
How can we fix it? We have two options. The first is we can copy the value before storing it:
julia> BorrowChecker.@safe function bar()
x = [1, 2]
foo(copy(x))
return x
end
bar (generic function with 1 method)
julia> bar() # ok
We no longer have access to the object created by copy(x), so the borrow check passes.
Alternatively, we can use immutable objects, which are safe to pass around:
julia> BorrowChecker.@safe function bar()
x = (1, 2)
foo(x)
return x
end
bar (generic function with 1 method)
julia> bar() # ok
@safe examples@safe analyzes the entire callstackBorrowChecker doesn't rely on naming conventions, such as the presence of ! in the function name. It tries to infer effects from IR:
julia> h(x) = (push!(x, 1); nothing) # no "!" in the name
h (generic function with 1 method)
julia> BorrowChecker.@safe function demo()
x = [1, 2, 3]
y = x
h(x)
return y
end
demo (generic function with 1 method)
julia> demo() # errors
kwcall IR)Keyword calls get lowered into a NamedTuple + Core.kwcall(...). @safe analyzes the lowered IR, so aliasing via keyword arguments is still visible:
julia> f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y)
f (generic function with 1 method)
julia> BorrowChecker.@safe function kw_demo()
x = [1, 2, 3]
y = x
return sum(f(; x=x, y=y))
end
kw_demo (generic function with 1 method)
julia> kw_demo() # errors
y = x: views can alias toojulia> BorrowChecker.@safe function view_demo()
x = [1, 2, 3, 4]
y = view(x, 1:2) # aliases x
push!(x, 9)
return collect(y)
end
view_demo (generic function with 1 method)
julia> view_demo() # errors
julia> BorrowChecker.@safe function closure_demo()
x = [1, 2, 3]
y = x
f = () -> (push!(x, 9); nothing)
f()
return y
end
closure_demo (generic function with 1 method)
julia> closure_demo() # errors
Read-only captures are typically fine.
@unsafe blocks@unsafe is an escape hatch analogous to @inbounds: inside the block, the
checker skips aliasing and escape validation entirely, and effects inside it
(writes, consumes, new aliases) do not propagate outward into the surrounding
analysis. You take responsibility for upholding the borrow rules yourself.
julia> BorrowChecker.@safe function add_halves!(a::Vector{Float64})
n = length(a) ÷ 2
BorrowChecker.@unsafe begin
left = @view a[1:n]
right = @view a[(n + 1):(2n)]
left .+= right
end
return a
end
@unsafe also works on a single expression, e.g. to silence one unanalyzable
call in an otherwise checked function:
julia> BorrowChecker.@safe w_gradient(f, x) =
BorrowChecker.@unsafe gradient(f, backend, x)
Set borrow_checker = false in your LocalPreferences.toml (via
Preferences.jl) to make every macro pass through untouched, or call
disable_by_default!(@__MODULE__) at the top of a module to ship it disabled
and enable checking in your test suite.
158 followers · starred Jan 2026
30 followers · starred Jul 2025
127 followers · starred Jan 2025
460 followers · starred Jan 2025
A borrow checker for Julia
See the codeThis is an experimental package for emulating a runtime borrow checker in Julia, using a macro layer over regular code. This is built to mimic Rust's ownership, lifetime, and borrowing semantics. This tool is mainly to be used in development and testing to flag memory safety issues, and help you design safer code.
BorrowChecker.jl provides one layer:
Automatic checking (BorrowChecker.@safe)
In Julia, when you write x = [1, 2, 3], the actual object exists completely independently of the variable, and you can refer to it from as many variables as you want without issue:
x = [1, 2, 3]
y = x
println(length(x))
# 3
Once there are no more references to the object, the "garbage collector" will work to free the memory.
Rust is much different. For example, the equivalent code is invalid in Rust
let x = vec![1, 2, 3];
let y = x;
println!("{}", x.len());
// error[E0382]: borrow of moved value: `x`
Rust refuses to compile this code. Why? Because in Rust, objects (vec![1, 2, 3]) are owned by variables. When you write let y = x, the ownership of vec![1, 2, 3] is moved to y. Now x is no longer allowed to access it.
To fix this, we would either write
let y = x.clone();
// OR
let y = &x;
to either create a copy of the vector, or borrow x using the & operator to create a reference. You can create as many references as you want, but there can only be one original object.
This "ownership" paradigm can help improve safety of code. Especially in complex, multithreaded codebases, it is easy to shoot yourself in the foot and modify objects which are "owned" (editable) by something else. Rust's ownership and lifetime model makes it so that you can prove memory safety of code! Standard thread races are literally impossible. (Assuming you are not using unsafe { ... } to disable safety features, or the borrow checker itself has a bug, etc.)
In BorrowChecker.jl, we demonstrate an implementation of some of these ideas. The aim is to build a development layer that can help prevent a few classes of memory safety issues, without affecting runtime behavior of code.
BorrowChecker.@safeBorrowChecker.@safe automatically instruments a function by analyzing the compiler IR and runs a best-effort borrow check at runtime. This requires Julia 1.12 or 1.13 (on newer versions the checker falls back to warn-and-pass-through stubs until support lands).
[!WARNING] This macro is highly experimental and compiler-dependent. There are likely bugs and false positives. It is intended for development and testing, and does not guarantee memory safety.
@safe supports a few options that are compiled into a BorrowChecker.Config:
scope (default :function): whether to recursively borrow-check callees (:none, :function, :module, :user, :all).max_summary_depth (default 12): recursion depth limit for effect summarization when effects cannot be directly resolved.optimize_until (default varies): which compiler pass to stop at when fetching IR (Base.code_ircode_by_type).scope meanings:
:none: disable @safe entirely.:function: check only the annotated method.:module: recursively check callees defined in the module where @safe is used.:user: recursively check callees, but ignore Core and Base (including their submodules).:all: recursively check callees across all modules (very aggressive).The @safe checked-cache is keyed by specialization and these options, so checking a function once under scope=:function will not incorrectly skip a later recursive check under scope=:module / :all.
@safe is meant to be a drop-in tripwire for existing code:
This analyzes the compiler’s IR, so it can catch patterns that are "hidden" by lowering (keyword calls, closure captures, views, etc.). It is intentionally best-effort: when it cannot determine what a call does, it will be conservative (and may throw false positives). For regions where the checker is overly conservative, silence them with @unsafe blocks.
When you write:
BorrowChecker.@safe function f(args...)
# ...
end
the macro rewrites the function so that:
f(::Vector{Int})), and caches the result (so future calls are faster).BorrowCheckError with a source-level-ish diagnostic.BorrowChecker.jl's @safe macro can detect when values are modified through aliased bindings, and throw an error:
julia> import BorrowChecker
julia> BorrowChecker.@safe function f()
x = [1, 2, 3]
y = x
push!(x, 4)
return y
end
f (generic function with 1 method)
julia> f() # errors
This will generate a helpful error pointing out the location of the borrow check violation, and the statement that violated the rule:
ERROR: BorrowCheckError for specialization Tuple{typeof(f)}
method: f() @ Main REPL[7]:1
[1] stmt#7: cannot perform write: value is aliased by another live binding at REPL[7]:4
2 x = [1, 2, 3]
3 y = x
> 4 push!(x, 4)
5 return y
6 end
stmt: Main.push!(%5, 4)
To fix it, simply copy the value, which will avoid the error:
julia> BorrowChecker.@safe function f()
x = [1, 2, 3]
y = copy(x)
push!(x, 4)
return y
end
f (generic function with 1 method)
julia> f()
3-element Vector{Int64}:
1
2
3
Much like Rust's ownership model, BorrowChecker.jl's @safe macro attempts to infer when values escape their scope (moved/consumed) and throw an error if they are used afterwards.
julia> const CACHE = Dict()
Dict{Any, Any}()
julia> foo(x) = (CACHE[x] = 1; nothing)
foo (generic function with 1 method)
julia> BorrowChecker.@safe function bar()
x = [1, 2]
foo(x)
return x
end
bar (generic function with 1 method)
julia> bar() # errors
This generates the following error:
ERROR: BorrowCheckError for specialization Tuple{typeof(bar)}
method: bar() @ Main REPL[13]:1
[1] stmt#6: value escapes/consumed by unknown call; it (or an alias) is used later at REPL[13]:3
1 BorrowChecker.@safe function bar()
2 x = [1, 2]
> 3 foo(x)
4 return x
5 end
stmt: Main.foo(%5)
Why is this an error? Because x was stored as a key in the cache, but is mutable externally. Furthermore, it is returned by bar! This is a violation of borrowing rules. Once the value gets stored in the cache, its ownership is transferred to the cache, and is no longer accessible by bar. So this example is illegal.
How can we fix it? We have two options. The first is we can copy the value before storing it:
julia> BorrowChecker.@safe function bar()
x = [1, 2]
foo(copy(x))
return x
end
bar (generic function with 1 method)
julia> bar() # ok
We no longer have access to the object created by copy(x), so the borrow check passes.
Alternatively, we can use immutable objects, which are safe to pass around:
julia> BorrowChecker.@safe function bar()
x = (1, 2)
foo(x)
return x
end
bar (generic function with 1 method)
julia> bar() # ok
@safe examples@safe analyzes the entire callstackBorrowChecker doesn't rely on naming conventions, such as the presence of ! in the function name. It tries to infer effects from IR:
julia> h(x) = (push!(x, 1); nothing) # no "!" in the name
h (generic function with 1 method)
julia> BorrowChecker.@safe function demo()
x = [1, 2, 3]
y = x
h(x)
return y
end
demo (generic function with 1 method)
julia> demo() # errors
kwcall IR)Keyword calls get lowered into a NamedTuple + Core.kwcall(...). @safe analyzes the lowered IR, so aliasing via keyword arguments is still visible:
julia> f(; x, y) = (push!(x, 1); push!(y, 1); x .+ y)
f (generic function with 1 method)
julia> BorrowChecker.@safe function kw_demo()
x = [1, 2, 3]
y = x
return sum(f(; x=x, y=y))
end
kw_demo (generic function with 1 method)
julia> kw_demo() # errors
y = x: views can alias toojulia> BorrowChecker.@safe function view_demo()
x = [1, 2, 3, 4]
y = view(x, 1:2) # aliases x
push!(x, 9)
return collect(y)
end
view_demo (generic function with 1 method)
julia> view_demo() # errors
julia> BorrowChecker.@safe function closure_demo()
x = [1, 2, 3]
y = x
f = () -> (push!(x, 9); nothing)
f()
return y
end
closure_demo (generic function with 1 method)
julia> closure_demo() # errors
Read-only captures are typically fine.
@unsafe blocks@unsafe is an escape hatch analogous to @inbounds: inside the block, the
checker skips aliasing and escape validation entirely, and effects inside it
(writes, consumes, new aliases) do not propagate outward into the surrounding
analysis. You take responsibility for upholding the borrow rules yourself.
julia> BorrowChecker.@safe function add_halves!(a::Vector{Float64})
n = length(a) ÷ 2
BorrowChecker.@unsafe begin
left = @view a[1:n]
right = @view a[(n + 1):(2n)]
left .+= right
end
return a
end
@unsafe also works on a single expression, e.g. to silence one unanalyzable
call in an otherwise checked function:
julia> BorrowChecker.@safe w_gradient(f, x) =
BorrowChecker.@unsafe gradient(f, backend, x)
Set borrow_checker = false in your LocalPreferences.toml (via
Preferences.jl) to make every macro pass through untouched, or call
disable_by_default!(@__MODULE__) at the top of a module to ship it disabled
and enable checking in your test suite.
158 followers · starred Jan 2026
30 followers · starred Jul 2025
127 followers · starred Jan 2025
460 followers · starred Jan 2025