Skip to main content

Julia Developing Tips

Mitch Phillipson 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
true

The 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
end

If 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
end

Notice 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
end

You 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)
end

If 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]
end

Let’s step through this code.

  1. First we define an abstract type called AbstractPoint. This will be the parent type for all point types.
  2. 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 of AbstractPoint must implement this function.
  3. The distance function can then be defined for any subtype of AbstractPoint. The syntax p1::T, p2::T means that p1 and p2 must be the same type, T. The where T<:AbstractPoint means that T must be a subtype of AbstractPoint. I did this so that you can’t compute the distance between a Point2D and a Point3D.
  4. Finally, define two concrete types, Point2D and Point3D, that are subtypes of AbstractPoint. Each type implements the point_vector function.

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

This 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))")
end

We 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: Float64

Under 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")
end

And 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: Int64

Future 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