A tutorial for solving Differential Equations in Julia

My research involves solving high ordered highly coupled differential equations. And while working in last few years I have used many programming languages like C, Fortran, Python, Julia. I have also used softwares like Matlab and Mathematica for the purpose.

I have realised that each had its own advantages. But among all of these I found Julia to be the most superior among the computational capacity, variety, speed and ease of use. There already exist benchmarks published on the internet that looks at various ODE solvers or interfaces and rank them for their speed and efficiency. I will not be doing that in this post. This post is intended as a tutorial for those who wish to use Julia for solving differential equations. For more I would link to more resources at the end.

Julia?

For people who are new to Julia, I will in this post go through some introductory element and some syntax that will be good for people working with Julia, people who want to go into further details, again see links at the end. For people who have used python which is common for science researchers, Julia looks very similar and is easy to grasp. I moved from python to julia myself and found a lot of common syntax ideas. For people who want a tiny introduction to the syntax of Julia, you can jump below to the section below where I introduce some concepts.

DifferentialEquations.jl

We can write our own differential equations solver using the Euler or Runge Kutta methods, but the developers have created a brilliant scientific library DifferentialEquations.jl which provides industry standard differential equations solver. You can install it using

julia>] add DifferentialEquations

One dimensional ODE (Logistic Growth)

Let us take an example to learn how to use DifferentualEquations.jl, we will start with a one dimensional ODE. Population growth with a carrying capacity (previously discussed in a blog post here.)

\[\frac{dN}{dt} =rN \cdot \left( 1- {\frac{N}{C}}\right) \]

Here C is the carrying Capacity of the population. This equation is famously known as the Logistic growth dynamical equation and is important in various fields like physics, medicine, computers, networks, and ecology.

Let us use DifferentialEquations to solve this linear differential equation.

using DifferentialEquations
f_logistic(u,p,t)=p[1] * u * (1 - u/p[2])
u0 = 0.25
tspan = (0.0, 10.0)
params=[0.5,1.0]
prob = ODEProblem(f_logistic, u0, tspan,params)
sol = solve(prob, Tsit5(), reltol = 1e-8, abstol = 1e-8)
@show sol.u
sol.u = [0.22, 0.2214624947856733, 0.22839406288432468, 0.2397665944624233, 0.25353691189172606, 0.2718027963182323, 0.29429750259628623, 0.3233955825445172, 0.39217810003773756, 0.4254078760000628, 0.47122980934119635, 0.5110669792190305, 0.5536344555821728, 0.5942082113800929, 0.6347729254986635, 0.6741582191045536, 0.7131345538896132, 0.7524902118492397, 0.7993766630870759, 0.8272052545288429, 0.854428424597453, 0.875866364292772, 0.894764782343281, 0.9105737408909818, 0.9242005455844378, 0.9357992086733474, 0.9457537584482427, 0.9542691501119392, 0.9615658394979313, 0.9678068498963645, 0.9731399616235411, 0.9766682882615482]

And voila, we have the solution. But how did that work? Let us take it step by step.

We always solve the system in two parts, we first define the problem: this involves defining (usually a function) the differential equations and then defining an ODEProblem object. The following line take care of first of these things

f_logistic(u,p,t)=p[1] * u * (1 - u/p[2])

Note the use of u is just arbitrary, I could just define it interms of N and it would still give me the right result. p is an array of parameters defined in the function and given to ODEProblem as an input. The function has three inputs, first is the variable itself, second is an array p that includes all the parameters, and last is time t. This particular ODE was not explicitly time dependent. But we can have systems with explicit time dependence, so we always keep it in the skeleton.

tspan is a tuple which tells the system the timescale upto which the solution needs to be solved. So the solution \(N(t)\) will be from \(N(0)\) to \(N(10)\). And last but not the least u0 tells us the initial value, as in essence what we are solving here is an initial value problem.

sol = solve(prob, Tsit5(), reltol = 1e-8, abstol = 1e-8)

This line then solves the prob defined using ODEProblem(f,u,t,p), the solve function takes the problem as an input, the solver we are using to solve the ODE (Tsit5()) here, and the tolerance values upto which we will be solving.

⚠ Note

abstol and reltol are different kind of tolerance values one can use for stopping the solver. They are absolute tolerance levels and relative tolerance levels. Absolute tolerance looks at successive values to check convergence, x{t+1}-x{t}<abstol, whereas the relative tolerance provides us with fractional convergence check.

The solver is a 5th order solver much like RK4-5. For people who have used matlab, it is equivalent to ode45 function there. Or for scipy users it is like the default solver of scipy's solve_ivp. Tsit5 uses a different parameter table than the usual RK45 method that gives better accuracy. See section below

This gives us an object sol, sol.u gives us \(N(t)\), whereas sol.t gives us an array with time values at which \(N(t)\) is evaluated. sol.t gives adaptive (non-uniform) time steps depending on the kind of solver.

The results we can then plot using any Plotting libraries in Julia. The most common is Plots.jl, the code to plot using Plots.jl is given below:

using Plots
function actual(r,C,n0,t)
    return C/(1+((C-n0)/n0)*exp(-r*t))
end
Plots.plot(sol, linewidth = 5, title = "Solution to the linear ODE and plotting with Plots.jl",xaxis = "Time (t)", yaxis = "N(t) (population)", label = "Numerical Solution (N(t))") 
Plots.plot!(sol.t, t -> actual.(params[1],params[2],u0,t), lw = 3, ls = :dash, label = "Actual Solution")
Plots.savefig("ODE1d.png")
 Solution of dN/dt with the solution aka the logistic function
Solution of dN/dt with the solution aka the logistic function

I prefer using CairoMakie.jl to do scientific plotting. It is more customisable and gives better quality plots (also personal choice it appears better to me). Feel free to check out documentation for cairomakie linked at the bottom of the post.

using CairoMakie,LaTeXStrings
fig=CairoMakie.Figure()
ax=CairoMakie.Axis(fig[1,1],xlabel=L"\text{Time }(t)",ylabel=L"N(t)\text{ (population)}",title="Solution and plotting using Makie")
lines!(ax,sol.t,sol.u,label=L"\text{Numerical Solution }(N(t))",color=:black,linewidth=5)
lines!(ax,sol.t,t -> actual.(params[1],params[2],u0,t),label=L"\text{Actual Solution}",linestyle=:dash,color=:red,linewidth=3)
axislegend(position=:lt)
CairoMakie.save("Blog/2026/images/MakieODE.png",fig)
 Same Solution using CairoMakie
Same Solution using CairoMakie

Higher dimensional ODE (Lorenz System, Chaos and strange attractor)

The most famous example for a higher dimensional Ordinary differential equations system is the lorenz system. It has captured people's attention as the system that introduced the world to Chaos. We will try to solve the lorenz system using

\[ \begin{aligned} \frac{dx}{dt} &= a(y-x)\\ \frac{dy}{dt} &= x(b-z)-y\\ \frac{dz}{dt} &= xy-cz \end{aligned} \]

Unlike the previous system, this one is non-linear and coupled. These equations actually represent fluid dynamics, where \(x\),\(y\) and \(z\) are different variables related to the rate of flow and the temperature profile of the fluid. It is not important what these physically mean for now. Parameters \(a\),\(b\) and \(c\) are important for studying different kinds of behaviours for the lorenz system.

We will take advantage of an important concept introduced in the section below. Mutable functions, or in-place updating functions, represented by the exclamation mark at the end of the name are used for better efficiency. This updates the variables that are inputs of the function. i.e. it updates already allocated variables. The compiler does not need to allocate the output of the function to another memory space and makes the solver much more efficient. It get important when working with large \(N\) problems.

We can define the lorenz model by

function lorenz_ODE!(du,u,p,t)
    du[1]=p[1]*(u[2]-u[1])
    du[2]=u[1]*(p[2]-u[3]) - u[2]
    du[3]=u[1]*u[2] - p[3]*u[3]
end

Note the extra du input to the function, this represents the du/dt values and it updates as the system solves for the solution. I can write it like this to make it more clear:

function lorenz_ODE!(du,u,p,t)
    dx,dy,dz=du[1],du[2],du[3]
    x,y,z=u[1],u[2],u[3]
    a,b,c=p[1],p[2],p[3]
    dx=a*(y-x)
    dy=x*(b-z)-y
    dz=x*y-c*z
end

It is better to write it in terms of arrays, instead of this with multiple variables for efficiency and it will be clear later why the former is better.

Now we can solve it just the way we did for the linear case. Defining the parameters:

params=[10.0,15.0,5.0]
tspan=(0.0,100.0)
uo=[1.0,0.0,0.0]

And the problem:

problem=ODEProblem(lorenz_ODE!,u0,tspan,params)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
    Non-trivial mass matrix: false
    timespan: (0.0, 10.0)
    u0: 3-element Vector{Float64}:
    1.0
    0.0
    0.0

Finally the solution, just using the default solver:

sol=solve(problem)

Okay, now we have the solution, let us plot it, I am going to use my trusty CairoMakie for plotting. You can use any library.

fig=CairoMakie.Figure()

ax1=CairoMakie.Axis(fig[1,1],xlabel="t",ylabel="x")
ax2=CairoMakie.Axis(fig[1,2],xlabel="t",ylabel="y")
ax3=CairoMakie.Axis(fig[2,1],xlabel="t",ylabel="z")
ax4=CairoMakie.Axis3(fig[2,2])

x=[sol.u[i][1] for i in 1:length(sol.u)]
y=[sol.u[i][2] for i in 1:length(sol.u)]
z=[sol.u[i][3] for i in 1:length(sol.u)]

CairoMakie.lines!(ax1,sol.t,x)
CairoMakie.lines!(ax2,sol.t,y)
CairoMakie.lines!(ax3,sol.t,z)
CairoMakie.lines!(ax4,x,y,z)
fig
 Solution of the lorenz equations using DifferentialEquations.jl for parameters:[10.0,15.0,5.0]. The bottom right plot shows the `attractor` in the phase space. Other three plots are x,y and z with time.
Solution of the lorenz equations using DifferentialEquations.jl for parameters:[10.0,15.0,5.0]. The bottom right plot shows the `attractor` in the phase space. Other three plots are x,y and z with time.

We can see for the parameters [10.0,15.0,5.0]. we get a spiral attractor, i.e. all three values converge to a single point \((X,Y,Z)\).

We can change these parameters to get different results. With params=[10.0,28.0,8/3], we get the famous buttterfly wing shaped lorenz attractor, also known as the strange attractor.

 The Chaotic Lorenz attractor visualised in the phase space.
The Chaotic Lorenz attractor visualised in the phase space.

This chaotic strange attractor is present for a value of \(b\) beyond a critical value \(b_c\) for fixed \(a\) and \(c\) values. There are surprisingly a few interesting special values of \(b\) that shows periodic behaviour in the middle of chaos, for example, the following image is for params=[10.0,100.5,8/3]. The phase portrait is now colored by time, notice the late time convergence to a periodic attractor shown with yellow colored trajectory. This parameter value is originally from Chapter 4 of The Lorenz Equations by Colin Sparrow (1992, Springer) where he recognised multiple stable attractors.

 Stable periodic orbit for b=100.5. The phase trajectory is colored by the time
Stable periodic orbit for b=100.5. The phase trajectory is colored by the time

For fun, let us also show chaos, i.e. the sensitive dependence on initial conditions. I solve the ODEs for two intial conditions which are extremely close. u0=[1.0,0.0,0.0] and u01=[1.0,0.0,0.0+1e-9]. The starting position of z is just different by \(10^{-9}\). This slight difference leads to very different final result. We plot the \(x\) v/s \(t\) plot for both cases and also the difference between them.

 Chaos visualised, sensitive dependence on initial conditions. Note the difference is as big as the amplitude after a while.
Chaos visualised, sensitive dependence on initial conditions. Note the difference is as big as the amplitude after a while.

Generalised Lotka Volterra Model (Ecological Predator-Prey Dynamics)

Alright, this is what we have been building upto, we are now going to be exploring a very high dimensional coupled ODE. Ecological systems like food webs can have hundreds of species interacting together and feeding in to each other. Solving such systems by explicitly writing the functions for each species could become, not impossible but a daunting task. What can we do?

We use a clever technique and take advantage of repeating functional forms for various species. Before defining the function though, let me talk about the generalised lotka volterra model, I have already talked about the prey predator dynamics and the lotka voltterra model in a previous post here, curious few can check it out. Let us dive straight in to the more generalised lotka volterra dynamics.

The GLV generalises ecological relationships between \(N\) species. The individual population \(x_i\) of \(i^{th}\) species evolves by the following set of ordinary differential equations:

\[ \frac{dx_{i}}{dt}=x_{i}\left(r_{i}+\sum _{j=1}^{n}A_{ij}x_{j}\right), \]

Here, \(r_i\) are intrinsic growth rate of the species \(i\). It can be positive for birth , negative for death. This parameter combines both birth and death rate of the species. The value \(A_{ij}\) is the component of the interaction matrix, it gives us the effect of the species \(j\) has on to species \(i\). The diagonal terms, \(A_{ii}\) are taken to be negative, this provides a self-inhibition that stops exponential growth of individual population to infinity.

Okay, now that we know what the parameters are, let us solve the system, starting with defining the function. We are going to use all the tools that we have learnt together.

Note that we can distribute the equations in two parts:

\[ \frac{dx_{i}}{dt}=x_{i} \cdot f_i, \quad f_i=\left(r_{i}+\sum _{j=1}^{n}A_{ij}x_{j}\right) \]

And \(\sum _{j=1}^{n}A_{ij}x_{j}\) is nothing but the \(i^{th}\) element of the product between \(A\) and \(\vec{X}\). So we are going to use LinearAlgebra.jl library and its in-place multiplication function mul!() to get Ax first. Then we use the for loop to get dx[i].

````
Function to generate the generalised lotka volterra model
````
using LinearAlgebra
function GLV!(dx,x,par,t)
    ###############################################
    #par[1]=A_ij  par[2]=r(s)
    ###############################################
    Ax=zeros(eltype(x),length(x))  # creating a zero matrix of x datatype and with N=length(x) entries
    mul!(Ax,par[1],x) # Ax=A times X
    for i in 1:length(x) 
        fi=par[2][i] + Ax[i]
        dx[i]=x[i]*(fi)
    end
end

Notice, how this generalizes for any \(N\), by using \(length(x)\) to determine \(N\). We can simplify this further by performing element wise operations that Julia allows.

````
Simpler function to generate the generalised lotka volterra model
````
using LinearAlgebra
function GLV_simple!(dx,x,par,t)
    ###############################################
    #par[1]=A_ij  par[2]=r(s)
    ###############################################
    Ax=zeros(eltype(x),length(x))
    mul!(Ax,par[1],x)
    fi=par[2] .+ Ax
    dx .= x.*(fi)
end

The for loop vanishes helping computational speed and making it very simple. We could infact write the last two lines in just one dx.= x.*(par[2] .+ Ax) for even better efficiency. But I choose to divide it for easier understanding.

Case 1: Stable coexistence:

If we have weak inter-species interaction and strong self regulation, we usually end up with coexistnece. Here is a 3-species manufactured example. Off diagonal elements are considerably smaller than the diagonal elements. We keep intrinsic growth rates as constant.

N=3
A = [-1.0  -0.1  -0.05;
     -0.1  -1.0  -0.1;
     -0.05 -0.1  -1.0]
R = [1.0, 1.0, 1.0]
X0=Random.rand(N)
X0=X0./sum(X0)
params=[A,R]
prob=ODEProblem(GLV_simple!,X0,(0.0,100.0),params,reltol=1e-14)
sol=solve(prob,
        Tsit5(),
    save_everystep=true)
vec=abs.(sol.u[length(sol.u)])
println(vec)
Plots.plot(sol,title="Species Population with time",xlabel="t")
Plots.savefig("3species_coexist.png")
 3 Species coexisting
3 Species coexisting

We can generalise it for a higher dimensional system making sure \(A_{ij}> |A_{ii}|\). In the following image, we sample the interaction elements from a normal distribution with mean 0.3 and standard deviation 0.1. Keeping diagonal elements at \(-1\) and also sampling \(r_i\) from a normal distribution around 0.5 with standard deviaiton 0.1 (Code below).

 10 species coexisting in a random ecological system generated by the rules stated above
10 species coexisting in a random ecological system generated by the rules stated above

Case 2: Competitive Exclusion

Competitive exclusion is the idea in ecology which says that two species cannot coexist in one niche assuming resources are finite. either one or both species would eventually die.

We can model this behaviour by considering a toy model with

\[ A=\begin{pmatrix} -1.0 & -1.5 \\ -1.5 & -1.0 \end{pmatrix} \text{ and } R=[1,1] \]

Solving this model, we notice it is dependent on the initial condition, if we start with more of species 1, I get that species taking over in the ecosystem.

 Competitive exclusion for two species
Competitive exclusion for two species

We can model it for higher species using our code, by considering the \(A_{ij}\) to be higher than the diagonal but of similar values. And higher than their own intrinsic growth rate. Here I sample the off diagonal elements from normal distribution with \(\mu =-1.3 \, \sigma = 0.1\)

 Competitive Exclusion principle for 10 species. Only species 7 survives , rest all die.
Competitive Exclusion principle for 10 species. Only species 7 survives , rest all die.

Case 3: Limit Cycle (oscillations)

If we take a pure predator-prey model, we usually see oscillatory patterns. A prey predator system have opposite signs in the symmetric values of the interaction matrix. \(A_{ij}=-A_{ji}\). We use small self regulation term to ensure species do not go extinct and can sustain oscilation. And we ensure predators have negative intrinsic growth rate because otherwise, preys would not be sufficient and it will not show cycles.

A = [-0.01   -1.0;
      0.5    -0.01]
R = [1.0, -1]  # predator has negative intrinsic growth
X0=[1/2,1/2]
params=[A,R]
prob=ODEProblem(GLV_simple!,X0,(0.0,50.0),params,reltol=1e-14)
sol=solve(prob,
        Tsit5(),
    save_everystep=true)
Plots.plot( 
    Plots.plot(sol,title="Species Population with time",xlabel="t"),
    Plots.plot(sol,idxs=(1,2),title="Phase Portrait"))
 Plot for prey predator dynamics between two species showing oscillatory solution
Plot for prey predator dynamics between two species showing oscillatory solution

We can cleverly study oscillations and choatic behaviour in higher dimensional LV dynamics. But I will leave it for another day. This post is already too long.

Conclusion

I hope this blog post would help someone who is starting out with solving Ordinary Differential Equations using Julia. I understand the learning curve could be a little difficult at first to climb, but it is smooth scaling once you get a hang of it.

Appendix

Short introduction to Julia's syntax

You can use Julia in its REPL, or download the IJulia package to use Julia in your jupyter notebook. Also see Pluto.jl for Julia's own unique notebook environment for more advanced users.

There are various datatypes much like python:

w=5
x=1.0
y=true
z=2 + 1im
typeof(w),typeof(x), typeof(y), typeof(z) # Diffenent Datatypes
(Int64, Float64, Bool, Complex{Int64})

We can create for loops, while loops and perform all basic operations:

for x in [1,10,4,5]
    println("x=$x, x^2 = $(x^2), x+1=$(x+1), x/2=$(x/2)") #$ can be used to print variables in string, println adds a newline at the end
end
x=1, x^2 = 1, x+1=2, x/2=0.5
x=10, x^2 = 100, x+1=11, x/2=5.0
x=4, x^2 = 16, x+1=5, x/2=2.0
x=5, x^2 = 25, x+1=6, x/2=2.5

We can use arrays, and push elements into arrays. A fun thing that julia offers is using Latex symbols in code, for example the use of \(\in\) instead of in:

a=[]
for x ∈ 1:4
    push!(a,x)
end
@show a
println("You can also remove elements from the array")
pop!(a) # remove last element
@show a
popfirst!(a) # or remove first
@show a
a = Any[1, 2, 3, 4]
You can also remove elements from the array
a = Any[1, 2, 3]
a = Any[2, 3]

You can also use other latex symbols like \(\neq\), \(\notin\), \(\ge\), etc for computation.

a = [4, 5, 6]
b = Int8[4, 5, 6]
@show a
@show b
println("Arrays are typed — once defined, you cannot push an incompatible type:")
try
    push!(b, 2.5)   # Float64 into an Int8 array → error
catch e
    println("Caught error: ", e)
end
println("But this works fine:")
push!(b, Int8(7))
@show b
a = [4, 5, 6]
b = Int8[4, 5, 6]
Arrays are typed — once defined, you cannot push an incompatible type:
Caught error: InexactError(:Int8, Int8, 2.5)
But this works fine:
b = Int8[4, 5, 6, 7]

You can use inline for loop syntax for faster computation.

x=[1,2,3]
y=[var*4 for var in x]
@show y
y = [4, 8, 12]

A unique element of Julia is the use of element wise operations. Much like Python's numpy arrays, you can perform elementwise operations on Julia arrays, but the difference is that you can do that for any julia object and perform any functions elementwise.

x=range(1,10)
y=x.*2
@show y
z=[0.01,0.1,1,10,100,1000]
@show log10.(z)
y = 2:2:20
log10.(z) = [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0]

Any function can be broadcast elementwise with ., this is more general than numpy. We have used this feature in the GLV_simple!() function in the post.

f(x, a) = exp(-a * x) * sin(x)
t = 0:0.1:10
@show y = f.(t, 0.3)   # broadcast over t, scalar a

y = f.(t, 0.3) = [0.0, 0.09688289328380166, 0.18709972965370564, 0.27008513274559637, 0.34538308622605984, 0.412645385178517, 0.4716290381244547, 0.5221927082502216, 0.5642922874073557, 0.5979757001718782, 0.6233770377206788, 0.640710122557378, 0.6502616052522163, 0.6523836934156877, 0.6474866111765241, 0.6360308845603287, 0.6185195444499075, 0.5954903343404111, 0.5675080049728184, 0.5351567722308355, 0.4990330085122907, 0.4597382312295467, 0.41787244524589484, 0.37402788900566825, 0.3287832269511704, 0.28269822362190966, 0.23630892767904832, 0.1901233870634137, 0.14461790964634494, 0.10023387713065636, 0.05737511365949082, 0.016405804642763814, -0.02235104424331126, -0.058614623052723865, -0.0921468283971277, -0.12275229312365146, -0.1502779625493469, -0.17461224813789056, -0.19568379217188714, -0.2134598791318406, -0.2279445311474595, -0.23917632605026343, -0.2472259772447618, -0.252193714849179, -0.2542065073598053, -0.2534151624927088, -0.24999134488260424, -0.2441245470031795, -0.2360190480499283, -0.2258908936306141, -0.213964926975803, -0.20047190004879267, -0.18564569043705717, -0.16972064728197822, -0.15292908678534597, -0.13549895505405088, -0.11765167324129497, -0.09960017714471167, -0.08154716065823255, -0.06368352977260994, -0.04618707120416317, -0.029221337225147054, -0.012934745892305753, 0.0025401063597654434, 0.017086924861838743, 0.030605996584895816, 0.043014278451087705, 0.05424530430318891, 0.06424892207637221, 0.07299087383860349, 0.080452232289178, 0.08662870803074772, 0.0915298424624241, 0.09517810148585773, 0.09760788537987573, 0.09886447019110424, 0.09900289581810379, 0.0980868156461157, 0.09618732213071121, 0.09338176214436016, 0.08975255520367502, 0.08538602690071788, 0.08037126898339726, 0.0747990365817965, 0.06876069207336287, 0.06234720403409254, 0.05564820864867913, 0.048751139863038584, 0.041740433470113664, 0.034696809236102924, 0.027696634110201256, 0.020811368526687513, 0.014107096812933094, 0.0076441417688952575, 0.001476762590162441, -0.004347065526096535, -0.00978579251958988, -0.01480435847344433, -0.01937418623529929, -0.02347310594634729, -0.027085216241410345]
Packages or libraries can be imported using the using command. Or you can use import and as:

using LinearAlgebra

A=diagm([1,2,3,4]) # Diagonal matrix
@show A
@show eigen(A).values # eigenvalues

import Statistics as st

@show st.mean(A)
A = [1 0 0 0; 0 2 0 0; 0 0 3 0; 0 0 0 4]
(eigen(A)).values = [1.0, 2.0, 3.0, 4.0]
st.mean(A) = 0.625

Julia functions can return multiple values as a tuple natively — no need for a container:

function stats(x)
    return minimum(x), maximum(x), sum(x)/length(x)
end
lo, hi, avg = stats([3, 1, 4, 1, 5, 9])
@show lo, hi, avg
(lo, hi, avg) = (1, 9, 3.8333333333333335)

About solvers

DifferentialEquations.jl provides a large variety of solvers. The choice of solver significantly affects speed and accuracy. Here are the some of the common ones:

SolverTypeWhen to use
Tsit5()Explicit RK (5th order)Default for non-stiff ODEs. As I said above, it is equivalent to ode45 in MATLAB
RK4()Explicit RK (4th order)Simple fixed-step problems
Vern7() / Vern9()Explicit RK (7th/9th order)High accuracy non-stiff problems
Rodas5()Implicit (stiff)Stiff systems
CVODE_BDF()Implicit (stiff)Very large stiff systems , import Sundials.jl package to use it
lsoda()Automatic stiffness switchingbest when you do not know if the system is stiff or not or it changes in between. Import LSODA.jl to use
KenCarp4()IMEXMixed stiff/non-stiff (e.g. reaction-diffusion)

A system is stiff when it contains dynamics at very different timescales simultaneously. Like fast-slow timescale problems. Explicit solvers like Tsit5 will take extremely small steps (very slow) on stiff systems. If your solver is slow or fails, try Rodas5().

You can also let the package choose automatically:

sol = solve(prob)   # auto-selects solver based on problem type

lsoda() is a julia wrapper for the classic Fortran LSODA solver. It is fast and robust for compisite problems.

DifferentialEquations.jl

Julia Learning Resources

Plotting

Previous posts referenced in this article

Code for plots

Competitive exclusion

Code for the figure for competitive exclusion for one species.

N=2
A = [-1.0  -1.5;
     -1.5  -1.0]
R = [1.0, 1.0]
X0=[1/2,1/2]
e=0.01
X01=[0.5+e,0.5]
X02=[0.5,0.5+e]
params=[A,R]
prob=ODEProblem(GLV_simple!,X0,(0.0,50.0),params,reltol=1e-14)
sol=solve(prob,
        Tsit5(),
    save_everystep=true)
prob1=ODEProblem(GLV_simple!,X01,(0.0,50.0),params,reltol=1e-14)
sol1=solve(prob1,
        Tsit5(),
    save_everystep=true)
prob2=ODEProblem(GLV_simple!,X02,(0.0,50.0),params,reltol=1e-14)
sol2=solve(prob2,
        Tsit5(),
    save_everystep=true)
fig=CairoMakie.Figure()
ax=CairoMakie.Axis(fig[1,1],title=L"X_0=[0.5,0.5]")
ax1=CairoMakie.Axis(fig[2,1],title=L"X_0=[0.6,0.5]")
ax2=CairoMakie.Axis(fig[3,1],title=L"X_0=[0.5,0.6]")
x=[sol.u[i][1] for i in 1:length(sol.u)]
x1=[sol1.u[i][1] for i in 1:length(sol1.u)]
x2=[sol2.u[i][1] for i in 1:length(sol2.u)]
y=[sol.u[i][2] for i in 1:length(sol.u)]
y1=[sol1.u[i][2] for i in 1:length(sol1.u)]
y2=[sol2.u[i][2] for i in 1:length(sol2.u)]
CairoMakie.lines!(ax,sol.t,x,label="Species 1")
CairoMakie.lines!(ax,sol.t,y,label="Species 2")
CairoMakie.lines!(ax1,sol1.t,x1)
CairoMakie.lines!(ax1,sol1.t,y1)
CairoMakie.lines!(ax2,sol2.t,x2)
CairoMakie.lines!(ax2,sol2.t,y2)
Legend(fig[4,1],ax,tellwidth=false,nbanks=2)
CairoMakie.save("Blog/2026/images/competitiveexclusion.png",fig)

Coeexistence

Code for coexistince of multiple species:

N=10
A=zeros(Float64,N,N)
for i in 1:N 
    for j in 1:N
        if i==j
            A[i,j]=-1
        else
            A[i,j]= -0.3 + 0.1*randn() #2*Random.rand()-1#
        end
    end
end
X0=Random.rand(N)
X0=X0./sum(X0)
R=(1/2).+0.1.*Random.randn(N)
params=[A,R]
prob=ODEProblem(GLV_simple!,X0,(0.0,100.0),params,reltol=1e-14)
sol=solve(prob,
        Tsit5(),
    save_everystep=true)
Plots.plot(sol,xrange=(0,100))

Lorenz Sensitivity

fig=CairoMakie.Figure()
params=[10.0,28,8/3]
tspan=(0.0,100.0)
u0=[1.0,0.0,0.0]
u01=[1.0,0.0,0.0+1e-9]
problem=ODEProblem(lorenz_ODE!,u0,tspan,params)
sol=solve(problem,reltol=1e-10)
problem1=ODEProblem(lorenz_ODE!,u01,tspan,params)
sol1=solve(problem1,reltol=1e-10)
#ax4=CairoMakie.Axis3(fig[1,1],azimuth=1.275pi+pi/2)
ax1=CairoMakie.Axis(fig[1,1],xlabel="t",ylabel="x")
ax2=CairoMakie.Axis(fig[2,1],xlabel="t",ylabel="x1")
ax3=CairoMakie.Axis(fig[3,1],xlabel="t",ylabel="x1-x")
x=[sol.u[i][1] for i in 1:length(sol.u)]
y=[sol.u[i][2] for i in 1:length(sol.u)]
z=[sol.u[i][3] for i in 1:length(sol.u)]
x1=[sol1.u[i][1] for i in 1:length(sol1.u)]
y1=[sol1.u[i][2] for i in 1:length(sol1.u)]
z1=[sol1.u[i][3] for i in 1:length(sol1.u)]
#CairoMakie.lines!(ax4,x,y,z,linewidth=0.5,color=(:black,0.5))
#CairoMakie.lines!(ax4,x1,y1,z1,linewidth=0.5,color=(:red,0.5))
CairoMakie.lines!(ax1,sol.t[1:2000],x[1:2000])
CairoMakie.lines!(ax2,sol.t[1:2000],x1[1:2000])
CairoMakie.lines!(ax3,sol.t[1:2000],x[1:2000]-x1[1:2000])
fig
CairoMakie.save("Blog/2026/images/lorenzsensitivity.png",fig)

Lorenz Periodic

params=[10.0,100.5,8/3]
tspan=(0.0,100.0)
uo=[1.0,0.0,0.0]
problem=ODEProblem(lorenz_ODE!,u0,tspan,params)
sol=solve(problem,reltol=1e-10)
fig=CairoMakie.Figure()

ax1=CairoMakie.Axis(fig[1,1],xlabel="t",ylabel="x")
ax2=CairoMakie.Axis(fig[1,2],xlabel="t",ylabel="y")
ax3=CairoMakie.Axis(fig[2,1],xlabel="t",ylabel="z")
ax4=CairoMakie.Axis3(fig[2,2],azimuth=1.275pi+pi/2)

x=[sol.u[i][1] for i in 1:length(sol.u)]
y=[sol.u[i][2] for i in 1:length(sol.u)]
z=[sol.u[i][3] for i in 1:length(sol.u)]

CairoMakie.lines!(ax1,sol.t,x)
CairoMakie.lines!(ax2,sol.t,y)
CairoMakie.lines!(ax3,sol.t,z)
CairoMakie.lines!(ax4,x,y,z,linewidth=0.5,color=sol.t,colormap=:viridis)
fig
CairoMakie.save("Blog/2026/images/lorenzperiodic.png",fig)