Discover the Key Features and Updates in Julia 1.12
Explore the latest updates in Julia 1.12.

Modeling & Simulation, HPC, and Enterprise Software all under one roof.
Great Lakes Consulting Services, Inc., a premier Information Technology Consulting company, serving others in IT staffing, analytic consulting, business intelligence and application development since 2009. We now specialize in custom Julia software services as the trusted partner to JuliaHub for their Consulting Services. Since 2015, we’ve partnered together to develop high-performance Julia code for low-latency data visualization and analytic solutions, high performance financial modeling, Modeling and Simulation for multiple sciences, personal Julia training, and legacy code migration & evolution.
This post was written by Steven Whitaker.
A new version of the Julia programming language was just released! Version 1.12 is now the latest stable version of Julia.
This release is a minor release, meaning it includes language enhancements and bug fixes but should also be fully compatible with code written in previous Julia versions (from version 1.0 and onward).
In this post, we will check out some of the features and improvements introduced in this newest Julia version. Read the full post, or click on the links below to jump to the features that interest you.
- Improved Quality of Life with Redefinable Types
- New Way to Fix Function Arguments
- Progress Towards More Reasonable Executables
If you are new to Julia (or just need a refresher), feel free to check out our Julia tutorial series, beginning with how to install Julia and VS Code.
Improved Quality of Life with Redefinable Types
Julia 1.12 introduces a major quality of life improvement for package development by allowing types to be redefined. To illustrate, prior to Julia 1.12:
julia> struct A end
julia> struct A
x::Int
end
ERROR: invalid redefinition of constant Main.A
Stacktrace:
[1] top-level scope
@ REPL[2]:1
But in Julia 1.12, no error is thrown! Note, however, that any objects of the old type will still be of the old type---they don't automatically update to conform to the new type definition. For example:
julia> struct A end
julia> a1 = A()
A()
julia> struct A
x::Int
end
julia> a2 = A(1)
A(1)
julia> a1
@world(A, 38513:38515)()
Note that after redefining A,
a1 is denoted as being of type A
from a previous so-called "world age".
Importantly, when defining a method that dispatches on a type, it will be defined for the version of the type that existed at the time of method definition. Continuing from the previous example:
julia> f(a::A) = println("hello")
f (generic function with 1 method)
julia> f(a2)
hello
julia> f(a1) # Method doesn't exist for the old type
ERROR: MethodError: no method matching f(::@world(A, 38513:38515))
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(::A)
@ Main REPL[6]:1
Stacktrace:
[1] top-level scope
@ REPL[8]:1
julia> struct A # Update the type
x::Float64
end
julia> f(A(2.0)) # Method doesn't exist for the new type
ERROR: MethodError: no method matching f(::A)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(::@world(A, 38516:38521))
@ Main REPL[6]:1
Stacktrace:
[1] top-level scope
@ REPL[18]:1
In other words, if updating a type, be sure to update methods as well.
Often, however, when someone is working with a type and many methods that use the type for dispatch, they are developing a package. Package development works differently than just working in the REPL, so let's see how package development is affected by the ability to update types.
First, without proper tooling, Julia package development can be a slog, regardless of whether types (or anything else) can be updated. This is because normally, after a package is loaded, changes made to the package's source code don't take effect until Julia is restarted and the package is loaded again. In other words, the whole Julia runtime has to be started up again and code has to be reloaded even if just a single function in the package was updated.
Fortunately, Revise.jl exists. Revise.jl provides massive quality of life improvements for package developers by allowing changes to the package source code to take effect immediately, without restarting Julia.
The biggest caveat? Revise.jl couldn't handle changes to struct definitions. So, if you decide a struct in your package needs an extra field, you're out of luck, you have to restart Julia. And then if you decide the struct actually didn't need that field, too bad again, restart Julia.
But that all changes with Julia 1.12! This release adds a mechanism for redefining types that Revise.jl hooks into, removing arguably the most significant remaining pain point of Julia package development. The number of times a developer needs to restart Julia will decrease significantly with Julia 1.12, allowing for greater productivity.
New Way to Fix Function Arguments
Julia 1.12 introduces the Fix struct
that can be used to fix function arguments.
(Here, "to fix" means "to set",
not "to correct".)
Think of it as another way to define a closure.
One of the main uses of Fix
is purely for convenience,
particularly when creating closures
of functions with many inputs.
For example,
suppose we have the following (nonsensical) physical model:
function dynamics(velocity, resistance, gravity, position, friction, length, mass)
return velocity + resistance + gravity + position + friction + length + mass
end
Now,
suppose we have another function
that computes
(or accepts as input)
a particular value for mass
and then creates a new function
that computes dynamics with mass fixed.
Previously,
an anonymous function typically would be used,
so let's compare using an anonymous function
to using Fix:
mass = 30 # Some computed or given value
# Using an anonymous function.
# Note that the `let mass = mass` is essential
# to guarantee Julia doesn't box `mass`
# (see <https://docs.julialang.org/en/v1/manual/performance-tips/#man-performance-captured>).
# (Though also note that sometimes boxing does not occur
# even if the `let` block is omitted.)
dynamics_with_mass = let mass = mass
(velocity, resistance, gravity, position, friction, length) -> dynamics(velocity, resistance, gravity, position, friction, length, mass)
end
# Using `Fix`.
# The `7` indicates that we want to fix the 7th argument.
dynamics_with_mass_fix = Base.Fix{7}(dynamics, mass)
As you can see,
using Fix is much more concise.
But both
dynamics_with_mass
and dynamics_with_mass_fix
act as a function of six arguments
and compute dynamics with mass fixed
to a specific value.
Next,
suppose we want to compute the derivative of dynamics
as a function of velocity
for fixed values of the other parameters.
In this case,
there are six arguments that need to be fixed.
Fix allows for fixing just one argument,
but Fixes can be nested
to fix multiple arguments.
Let's compare using an anonymous function
to using Fix:
# Fixed values, either computed or given.
resistance = 2
gravity = 9.8
position = 0
friction = 0
length = 0.5
mass = 30
# Using an anonymous function.
f = let resistance = resistance,
gravity = gravity,
position = position,
friction = friction,
length = length,
mass = mass
velocity -> dynamics(velocity, resistance, gravity, position, friction, length, mass)
end
# Using `Fix`.
f_fix = Base.Fix{2}(Base.Fix{2}(Base.Fix{2}(Base.Fix{2}(Base.Fix{2}(Base.Fix{2}(dynamics, resistance), gravity), position), friction), length), mass)
# Using `Fix` with piping for nicer formatting.
pipefix(::Val{N}, x) where {N} = Base.Fix{2}(Base.Fix{N}, x)
f_pipefix = Base.Fix{2}(dynamics, resistance) |>
pipefix(Val{2}(), gravity) |>
pipefix(Val{2}(), position) |>
pipefix(Val{2}(), friction) |>
pipefix(Val{2}(), length) |>
pipefix(Val{2}(), mass)
In this case,
I would argue using an anonymous function
is clearer to read.
However,
Fix can have some performance advantages
over anonymous functions.
For example,
using Fix can reduce the need to compile
anonymous functions that are used repeatedly.
For a lot more context and discussion,
see the pull request
that introduced Fix.
Progress Towards More Reasonable Executables
Perhaps surprisingly,
an experimental feature
is probably the most highly anticipated addition in Julia 1.12:
a new command-line argument --trim
that enables a (currently experimental) feature
that removes unnecessary code
from compiled executables.
Along with the release of Julia 1.12, JuliaC.jl was also created to streamline the creation of executables. JuliaC.jl can be installed as a Julia app (another new feature with 1.12!):
pkg> app add JuliaC
Installing JuliaC.jl in this way
creates the juliac executable
at ~/.julia/bin/juliac.
(After installation,
you may want to add ~/.julia/bin
to your PATH.)
Let's see how to use juliac
with a simple program:
# main.jl
function @main(args::Vector{String})::Cint
for arg in args
# Note the use of `Core.stdout` instead of `stdout`
# (which is used by default if an `io` argument is omitted).
println(Core.stdout, arg)
end
return 0
end
This program simply prints to the console all its given command-line arguments.
To compile it into an executable, just run the following command:
juliac --output-exe print_args --project . --bundle bundle --trim main.jl
To verify it works:
$ ./bundle/bin/print_args hello world
hello
world
So,
what exactly is the impact of --trim?
Without the new (experimental) feature,
the size of the print_args executable
was 205 MB on my computer.
With --trim,
that number improved by over 100x,
dropping to just 1.7 MB!
Summary
In this post,
we learned about
some of the new features
and improvements
introduced in Julia 1.12,
including redefinable types,
Fix,
and --trim.
Curious readers can
check out the release notes
for the full list of changes.
What are you most excited about in Julia 1.12? Let us know in the comments below!
Additional Links
- Julia v1.12 Release Notes
- Full list of changes made in Julia 1.12.
- Julia Basics for Programmers
- Series of blog posts covering Julia basics.
- Diving into Julia
- Series of blog posts covering more advanced Julia concepts.





