Julia Developing Tips
September 26, 2025
Julia has many features that make developing packager easier, compared to Python or R. However, new comers to the language often miss out on some of these features. Here are some tips that I have found useful when developing packages in Julia.
I discussed this during the WiNDC weekly, that link has both a recording and a link to the repository.
Environments
I routinely use JuMP.jl, a package for mathematical
optimization that is very well maintained. My package,
MPSGE.jl, depends on JuMP.jl. If
JuMP.jl publishes a new version, I don’t want to
automatically get that new version, because it might break my package.
An environment allows me to “freeze” the versions of the packages that
my package depends on.
If you are familiar with Python you’ve probably used a
virtual environment. A virtual environment in Python is a
new python installation with only the packages you want. I don’t know
how R solves this problem, if you do, please let me know.
In Julia environments are defined by two files:
Project.toml and Manifest.toml. The
Project.toml is the file that you edit and stores direct
dependencies and versions. The Manifest.toml is
automatically generated and stores the exact versions of all packages,
including indirect dependencies.
Creating an environment is easy. Start Julia in your directory,
activate the package manager with ] and type
activate .. You can then add packages with
add PackageName and Julia will automatically create a
Project.toml and Manifest.toml file. Here
is an example showing what that basic Project.toml
looks like.
At their most basic, a Project.toml is just a list of
packages. However, you can also specify versions. Here
is an example with a [compat] section that specifies
the versions of packages that are compatible with this package. This
Project.toml is part of a package, you can tell by the four
lines of metadata at the top.
One last, more
complex example, the Project.toml for
MPSGE.jl. Really, the only difference is the
[extras] section, which is used to define optional
dependencies. In this case those are only used for testing, which you
can see in the [targets] section.
You can find more information about Julia environments in the official
documentation. If you want to dig deeper into the
Project.toml format, see the specification.
Use Revise.jl
Because I use environments, my base Julia installation has very few
packages installed. One of those packages is Revise.jl.
Revise.jl automatically tracks changes to your code and
reloads it in your current Julia session. This means that you can edit
your code in your favorite text editor, save the file, and then
immediately test the changes in Julia without needing to restart Julia
or re-include the file.
This is one of those packages that you didn’t know you needed until you use it. Just install it in your base Julia installation and that’s it.
Types
Every variable in Julia has a type. You can see the type of a
variable with the typeof function. For example,
typeof(3) returns Int64, which is one of the
integer types. And Int64 is an example of a
concrete type, or a type that a variable can actually have.
There is also the abstract type Integer, which
is the parent type of all integer types. The number 3 is an
Int64 so it is also an Integer, but it is not
an Int32. You can type check with isa,
julia> 3 isa Integer
true
julia> 3 isa Int32
false
julia> 3 isa Int64
trueThe practical way to use these types is in function definitions. You can define a function that only accepts integers like this:
function f(x::Integer)
return x + 1
endIf I try to call f(3.5) I get an error, because
3.5 is a Float64, not an Integer.
But f(3) works fine. I can extend this to multiple
types:
function f(x::Real)
return x^2
endNotice f(3) is still 4. Julia will use the
most specific method available. Because Integer is a
subtype of Real, the first method is more specific than the
second. The most abstract type is Any, if you don’t specify
a type, the function will accept any type.
Custom Types
A struct is a custom type that you define. For
example,
struct Point
x::Float64
y::Float64
endYou can then create a Point with
p = Point(1.0, 2.0), the fields x and
y can be accessed with p.x and
p.y (although this isn’t best practice for packages).
Because Point is a type, you can use it in function
definitions:
function distance(p1::Point, p2::Point)
return sqrt((p1.x - p2.x)^2 + (p1.y - p2.y)^2)
endIf you are not developing a package, this is a fine way to use types.
However, if you are developing a package, it is better to use an
abstract type and define methods for that type. Let’s redo
our previous example:
abstract type AbstractPoint end
function point_vector(p::AbstractPoint)
error("point_vector not implemented for $(typeof(p))")
end
function distance(p1::T, p2::T) where T<:AbstractPoint
return sqrt(sum((point_vector(p1) .- point_vector(p2)).^2))
end
struct Point2D <: AbstractPoint
x::Float64
y::Float64
end
function point_vector(p::Point2D)
return [p.x, p.y]
end
struct Point3D <: AbstractPoint
x::Float64
y::Float64
z::Float64
end
function point_vector(p::Point3D)
return [p.x, p.y, p.z]
endLet’s step through this code.
- First we define an
abstract typecalledAbstractPoint. This will be the parent type for all point types. - This has a single interface function,
point_vector, that takes a point and returns its coordinates as a vector. The default implementation throws an error, so any subtype ofAbstractPointmust implement this function. - The distance function can then be defined for any subtype of
AbstractPoint. The syntaxp1::T, p2::Tmeans thatp1andp2must be the same type,T. Thewhere T<:AbstractPointmeans thatTmust be a subtype ofAbstractPoint. I did this so that you can’t compute the distance between aPoint2Dand aPoint3D. - Finally, define two concrete types,
Point2DandPoint3D, that are subtypes ofAbstractPoint. Each type implements thepoint_vectorfunction.
Now we can use the distance function with both
Point2D and Point3D:
julia> p1 = Point2D(1.0, 2.0)
Point2D(1.0, 2.0)
julia> p2 = Point2D(4.0, 6.0)
Point2D(4.0, 6.0)
julia> distance(p1, p2)
5.0
julia> p3 = Point3D(1.0, 2.0, 3.0)
Point3D(1.0, 2.0, 3.0)
julia> p4 = Point3D(4.0, 6.0, 8.0)
Point3D(4.0, 6.0, 8.0)
julia> distance(p3, p4)
7.0710678118654755This structure allows other uses to easily extend your package by
defining new subtypes of AbstractPoint and implementing the
point_vector function. This is a powerful way to use types
in Julia packages.
It is bad practice to access the fields of a struct directly, like
p.x. Instead, define functions that access the fields.
That’s the purpose of the point_vector function in the
example above.
Multiple Dispatch
Julia’s core feature is multiple dispatch. This means
that functions can have multiple methods, and Julia will choose the most
specific method based on the types of the arguments. What does look like
in practice?
Let’s build up the function f. First, the least specific
method:
function f(x,y)
println("Default: x: $(typeof(x)), y: $(typeof(y))")
endWe can use this method with any types:
julia> f(1, 2)
Default: x: Int64, y: Int64
julia> f(1.0, "hello")
Default: x: Float64, y: String
julia> f("hello", 1.0)
Default: x: String, y: Float64Under the hood, Julia has compiled three difference functions, one for each combination of argument types. Let’s add a more specific method:
function f(x::Integer, y::Integer)
println("Both integers")
end
function f(x::String, y::String)
println("Both strings")
end
function f(x::Integer, y::String)
println("Integer and string")
end
function f(x::String, y::Integer)
println("String and integer")
endAnd now we can use these:
julia> f(1, 2)
Both integers
julia> f("hello", "world")
Both strings
julia> f(1, "hello")
Integer and string
julia> f("hello", 1)
String and integer
julia> f([1,2,3], 1)
Default: x: Vector{Int64}, y: Int64Future Topics
In the future I plan to cover more advanced topics, such as: -
Writing tests with Test.jl and
TestItemRunner.jl - Documenting packages with
Documenter.jl and Literate.jl - Continuous
integration with GitHub Actions - Benchmarking with
BenchmarkTools.jl - Type stability and performance tips -
Using macros to reduce boilerplate code