LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Julia: High-Performance Scientific Computing

juliascientific-computingperformancenumerical-computingdata-sciencemultiple-dispatch
Contents

Julia: High-Performance Scientific Computing

For most numerical work, scientists and engineers live in a two-language world. You prototype in Python or R because the ecosystem is rich, the syntax is expressive, and iteration is fast. Then, when performance matters — the simulation that takes 8 hours in NumPy, the ODE solver that needs to run millions of times, the Monte Carlo sampler bottlenecked in pure Python — you rewrite the hot path in C, C++, or Fortran, wrap it with ctypes or f2py, and accept the cognitive overhead of maintaining two codebases in two languages with a FFI seam between them.

Julia was designed from the ground up to eliminate this tradeoff. The pitch: a language that reads like Python or MATLAB, reaches for abstractions when you want them, but compiles to machine code via LLVM and runs at C speeds without a rewrite step.

That pitch is largely true. It comes with genuine costs. This guide covers both.


The Two-Language Problem

The two-language problem isn’t just about inconvenience. It’s about a structural friction that affects what science gets done.

When prototype code and production code live in different languages, experimentation slows. A researcher who wants to add a new term to an ODE system can do it in Python in ten minutes, but the Fortran implementation that’s actually fast is someone else’s domain. The result is that either the system stays slow (research bottleneck) or the model stays simpler than it should be (scientific bottleneck).

Julia’s creators — Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and Alan Edelman at MIT — published a manifesto in 2012 describing what they wanted: something as fast as C, as dynamic as Ruby, as good at statistics as R, as natural for linear algebra as MATLAB, as good at gluing things together as Python, but unified in one language. Julia 1.0 shipped in 2018 with a stability guarantee: code written for Julia 1.0 will run unchanged on Julia 1.x. As of 2026, Julia 1.11 is current, and the ecosystem has matured significantly.


Installation and Getting Started

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install via juliaup (the official version manager, like rustup for Rust)
curl -fsSL https://install.julialang.org | sh

# Install a specific version
juliaup add 1.11
juliaup default 1.11

# Verify
julia --version
# julia version 1.11.2

# Start the REPL
julia

The Julia REPL has four modes:

  • julia> — normal Julia mode
  • ] — package manager mode (Pkg)
  • ; — shell mode
  • ? — help mode
1
2
3
4
5
6
7
# In the REPL:
# Press ] to enter Pkg mode
# pkg> add BenchmarkTools DataFrames Plots

# Press Backspace to return to Julia mode
# Press ; to run shell commands
# shell> ls -la

Multiple Dispatch: Julia’s Core Abstraction

If you’ve used Python, you know single dispatch: obj.method(args) — the method that gets called is determined by the type of obj alone. In Java and C++, method overloading is resolved at compile time and looks like multiple dispatch, but it isn’t — virtual dispatch still uses only the receiver type.

Julia is built around multiple dispatch: the method that gets called is determined by the types of all arguments at runtime (with JIT compilation making this fast). This is the core abstraction around which everything else is organized.

Defining Methods

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Define multiple methods for the same function name
# Julia picks the right one based on all argument types

function combine(a::Int, b::Int)
    println("Combining two integers: $(a + b)")
    return a + b
end

function combine(a::String, b::String)
    println("Combining two strings: $(a * b)")
    return a * b
end

function combine(a::Int, b::String)
    println("Int and String: $(repeat(b, a))")
    return repeat(b, a)
end

function combine(a::String, b::Int)
    println("String and Int: $(repeat(a, b))")
    return repeat(a, b)
end

combine(3, 4)          # → "Combining two integers: 7", returns 7
combine("hello", "!")  # → "Combining two strings: hello!", returns "hello!"
combine(3, "ha")       # → "Int and String: hahaha", returns "hahaha"

Why Multiple Dispatch Matters

In Python, if you want a function to behave differently based on two argument types, you write isinstance checks inside the function body, or you create a dispatch table, or you subclass and hope. None of these compose well.

Multiple dispatch composes. Consider a physics simulation with different material types and interaction rules:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Abstract types define the hierarchy
abstract type Material end
abstract type Metal <: Material end
abstract type Plastic <: Material end

# Concrete types
struct Steel <: Metal
    yield_strength::Float64  # MPa
    density::Float64          # kg/m³
end

struct Aluminum <: Metal
    yield_strength::Float64
    density::Float64
end

struct ABS <: Plastic
    tensile_strength::Float64
    density::Float64
end

# Define interactions based on material combinations
# This is where multiple dispatch shines: behavior from the combination

function friction_coefficient(a::Metal, b::Metal)
    # Metal-metal: depends on similar hardness
    return 0.15 + rand() * 0.05   # roughly 0.15–0.20 for lubricated metal
end

function friction_coefficient(a::Metal, b::Plastic)
    # Metal on plastic: lower friction
    return 0.05 + rand() * 0.03
end

function friction_coefficient(a::Plastic, b::Metal)
    # Symmetric by convention
    return friction_coefficient(b, a)
end

function friction_coefficient(a::Plastic, b::Plastic)
    # Plastic on plastic: varies widely
    return 0.2 + rand() * 0.15
end

# Usage
steel = Steel(250.0, 7850.0)
alum  = Aluminum(270.0, 2700.0)
abs_  = ABS(40.0, 1050.0)

μ1 = friction_coefficient(steel, alum)   # dispatches to Metal-Metal method
μ2 = friction_coefficient(steel, abs_)   # dispatches to Metal-Plastic method
μ3 = friction_coefficient(abs_, steel)   # dispatches to Plastic-Metal (calls Metal-Plastic)

New material types added later will automatically work with existing friction methods if they inherit the right abstract type, or define specialized methods if needed. The behavior comes from the interaction, not from any single type.

@which: Inspecting Dispatch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
using InteractiveUtils

# @which tells you exactly which method was selected
@which combine(3, 4)
# combine(a::Int64, b::Int64)
#     @ Main REPL[1]:2

@which friction_coefficient(steel, alum)
# friction_coefficient(a::Metal, b::Metal)
#     @ Main REPL[15]:2

# methods() shows all methods defined for a function
methods(friction_coefficient)
# 4 methods for generic function "friction_coefficient" from Main:
#  [1] friction_coefficient(a::Metal, b::Metal)
#  [2] friction_coefficient(a::Metal, b::Plastic)
#  [3] friction_coefficient(a::Plastic, b::Metal)
#  [4] friction_coefficient(a::Plastic, b::Plastic)

# methodswith() shows all methods that accept a given type
methodswith(Steel)

Multiple Dispatch vs OOP

In Python, behavior lives in objects:

1
2
3
4
5
6
7
class Matrix:
    def __add__(self, other):
        if isinstance(other, Matrix):
            return self._matrix_add(other)
        elif isinstance(other, (int, float)):
            return self._scalar_add(other)
        return NotImplemented

This puts the isinstance logic inside the class. If you want to add a new case — say, adding a SparseMatrix to a DenseMatrix — you modify Matrix.__add__. But if SparseMatrix comes from a different library, you can’t modify either class. This is the expression problem.

Multiple dispatch solves it cleanly. Anyone can define a new method for any combination of types, without modifying existing types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# Core library defines these types and basic operations
struct DenseMatrix
    data::Matrix{Float64}
end

struct SparseMatrix
    rows::Vector{Int}
    cols::Vector{Int}
    vals::Vector{Float64}
    m::Int
    n::Int
end

# Multiplication methods — can be defined anywhere, by anyone
import Base: *

function *(A::DenseMatrix, B::DenseMatrix)
    println("Dense × Dense: using BLAS DGEMM")
    DenseMatrix(A.data * B.data)
end

function *(A::SparseMatrix, B::DenseMatrix)
    println("Sparse × Dense: CSR-Dense multiply")
    # ... sparse matrix multiplication logic
end

function *(A::DenseMatrix, B::SparseMatrix)
    println("Dense × Sparse: transpose trick")
    # ...
end

function *(A::SparseMatrix, B::SparseMatrix)
    println("Sparse × Sparse: CSR-CSR multiply")
    # ...
end

# New type from a new package — extend without modifying core code
struct GPUMatrix
    device_ptr::UInt64
    m::Int
    n::Int
end

function *(A::GPUMatrix, B::DenseMatrix)
    println("GPU × Dense: cudaGemm")
    # ...
end

This extensibility is why Julia’s scientific ecosystem fits together so naturally. Different libraries defining different types can share operations without coordination.


The Type System

Julia’s type system is designed to serve two masters simultaneously: expressiveness (for the programmer) and specialization (for the compiler). Understanding it is key to writing fast Julia code.

Type Hierarchy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Abstract types: define the hierarchy, have no data fields
abstract type Number end
abstract type Real <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer <: Real end

# Concrete types: have data fields, are instantiated
struct Point2D{T<:Real}  # parametric concrete type
    x::T
    y::T
end

# Built-in type hierarchy
# Number
#   ├── Complex{T}
#   └── Real
#         ├── AbstractFloat
#         │     ├── Float16
#         │     ├── Float32
#         │     └── Float64     ← default floating point
#         ├── Integer
#         │     ├── Bool
#         │     ├── Signed
#         │     │     ├── Int8, Int16, Int32, Int64, Int128
#         │     └── Unsigned
#         │           └── UInt8, UInt16, UInt32, UInt64, UInt128
#         └── Rational{T}

# Check type relationships
Int64 <: Integer      # true
Float64 <: Real       # true
Float64 <: Integer    # false
Int64 <: Number       # true (transitivity)

Type Annotations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# :: is a type assertion, not a declaration
x::Int = 5           # x is declared as Int
x = 3.14             # ERROR: TypeError: in assignment, expected Int, got Float64

# In function signatures, :: dispatches on type
function scale(v::Vector{Float64}, s::Float64)
    return v .* s   # .* broadcasts element-wise
end

# Without annotation, function accepts any type
function scale_any(v, s)
    return v .* s
end

# isa check
isa(3, Int)           # true
isa(3, Number)        # true
isa(3.0, Int)         # false
isa(3.0, AbstractFloat)  # true

3 isa Number          # alternative syntax

Parametric Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# Parametric types are like generics in Java/Rust but more powerful
struct Stack{T}
    data::Vector{T}
end

function push_item!(s::Stack{T}, item::T) where T
    push!(s.data, item)
    return s
end

function pop_item!(s::Stack{T}) where T
    return pop!(s.data)
end

int_stack = Stack{Int}([])
push_item!(int_stack, 1)
push_item!(int_stack, 2)
push_item!(int_stack, 3)
pop_item!(int_stack)   # returns 3

str_stack = Stack{String}([])
push_item!(str_stack, "hello")
push_item!(str_stack, "world")
# push_item!(str_stack, 42)   # ERROR: MethodError, Int64 vs String

# Constrained parametric types
function dot_product(a::Vector{T}, b::Vector{T}) where T<:Number
    return sum(a[i] * b[i] for i in eachindex(a))
end

# Multiple type parameters
struct Pair{A, B}
    first::A
    second::B
end

p = Pair{String, Int}("age", 42)
p.first   # "age"
p.second  # 42

Union Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Union: a value can be one of several types
function process(x::Union{Int, Float64, String})
    if x isa Int
        return x * 2
    elseif x isa Float64
        return x ^ 2
    else
        return uppercase(x)
    end
end

process(5)      # 10
process(2.5)    # 6.25
process("hi")   # "HI"

# Union{T, Nothing} is the idiomatic Optional/Maybe
function find_first_positive(v::Vector{Float64})::Union{Float64, Nothing}
    for x in v
        if x > 0
            return x
        end
    end
    return nothing
end

result = find_first_positive([-1.0, -2.0, 3.0, 4.0])
if result !== nothing
    println("Found: $result")
end

Why the Type System Enables Performance

The key insight: Julia compiles specialized machine code for each unique combination of argument types. When you call f(x::Float64, y::Int64), Julia compiles a version specialized for exactly those types. The LLVM backend sees concrete types throughout and can optimize aggressively — no boxing, no dynamic dispatch, no runtime type checks in the hot path.

This is why Julia code that looks generic can still be fast. The generality is at the language level; the machine code is specific.


JIT Compilation and Performance

The Compilation Model

Julia uses a LLVM-based JIT compiler with a specific model: method specialization per type signature. The first time a function is called with a new combination of argument types, Julia:

  1. Infers types throughout the function body (type inference)
  2. Lowers to Julia IR
  3. Specializes on concrete types
  4. Compiles to LLVM IR
  5. Lets LLVM optimize and emit machine code

Subsequent calls with the same type signature hit the compiled machine code directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# This feels dynamic — no type annotations
function sum_squares(v)
    total = 0.0
    for x in v
        total += x^2
    end
    return total
end

# First call: Julia compiles a specialized version for Vector{Float64}
v = rand(1_000_000)
@time sum_squares(v)   # includes compile time
# 0.054321 seconds (includes JIT)

# Second call: hits compiled code
@time sum_squares(v)
# 0.002831 seconds (pure execution)

The Introspection Macros

Julia provides a stack of macros to look inside the compilation pipeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function add_scaled(x::Float64, y::Float64, scale::Float64)
    return x + scale * y
end

# @code_lowered: Julia's internal IR before type inference
@code_lowered add_scaled(1.0, 2.0, 3.0)
# CodeInfo
#  1 ─ %1 = scale * y
#  │   %2 = x + %1
#  └──      return %2

# @code_typed: after type inference — every value has a known type
@code_typed add_scaled(1.0, 2.0, 3.0)
# CodeInfo for (::typeof(add_scaled))(x::Float64, y::Float64, scale::Float64)
#  1 ─ %1 = Base.mul_float(scale, y)::Float64
#  │   %2 = Base.add_float(x, %1)::Float64
#  └──      return %2
# => Float64

# @code_llvm: LLVM IR — what gets optimized by LLVM
@code_llvm add_scaled(1.0, 2.0, 3.0)
# ; Function Signature: add_scaled(Float64, Float64, Float64)
# define double @julia_add_scaled(...) {
# top:
#   %0 = fmul double %scale, %y
#   %1 = fadd double %x, %0
#   ret double %1
# }

# @code_native: actual machine code (x86-64 assembly)
@code_native add_scaled(1.0, 2.0, 3.0)
# .text
# ; Function Signature: add_scaled(Float64, Float64, Float64)
#   vmulsd  %xmm2, %xmm1, %xmm1
#   vaddsd  %xmm1, %xmm0, %xmm0
#   retq
# Three instructions. Same as a C compiler would emit.

Three instructions for the machine code — identical to what a C or Fortran compiler produces. No boxing, no runtime dispatch overhead.

Type Stability: The Key to Performance

Type stability means a function always returns the same type for given argument types. Type instability is the most common cause of slow Julia code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# TYPE UNSTABLE — bad for performance
function unstable_mean(v)
    total = 0       # Int! This is wrong
    for x in v
        total += x   # Int + Float64 = Float64, type changes mid-loop
    end
    return total / length(v)  # return type depends on runtime values
end

# TYPE STABLE — fast
function stable_mean(v)
    total = zero(eltype(v))   # starts as 0.0 for Float64 arrays
    for x in v
        total += x   # Float64 + Float64 = Float64, type never changes
    end
    return total / length(v)  # always Float64
end

# @code_warntype highlights type instabilities in red
@code_warntype unstable_mean([1.0, 2.0, 3.0])
# Body::Union{Float64, Int64}   ← RED: unstable return type
#  1 ─       total = 0
#  ...

@code_warntype stable_mean([1.0, 2.0, 3.0])
# Body::Float64   ← GREEN: stable return type

# Performance difference is real
using BenchmarkTools
v = rand(1_000_000)

@btime unstable_mean($v)
# 12.4 ms (3 allocations: 48 bytes)  ← extra allocations from type changes

@btime stable_mean($v)
# 1.1 ms (0 allocations)  ← no allocations, pure numeric computation

Benchmarking with BenchmarkTools.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using BenchmarkTools

# @btime: benchmark with warmup, best of N runs, interpolates constants
function naive_dot(a::Vector{Float64}, b::Vector{Float64})
    s = 0.0
    for i in eachindex(a)
        s += a[i] * b[i]
    end
    return s
end

a = rand(10_000)
b = rand(10_000)

# $ interpolates variables to avoid benchmark overhead from global variable lookup
@btime naive_dot($a, $b)
# 6.2 μs (0 allocations: 0 bytes)

@btime dot($a, $b)      # LinearAlgebra.dot — uses BLAS
# 4.8 μs (0 allocations: 0 bytes)

# @benchmark gives detailed statistics
result = @benchmark naive_dot($a, $b)
# BenchmarkTools.Trial: 10000 samples with 1 evaluation.
#  Range (min … max):  5.7 μs … 22.3 μs
#  Time  (median):     6.1 μs               ← use median, not mean
#  Time  (mean ± σ):   6.3 μs ±  0.9 μs
#  Allocs: 0
#  Bytes: 0

# Compare functions
suite = BenchmarkGroup()
suite["naive"] = @benchmarkable naive_dot($a, $b)
suite["blas"]  = @benchmarkable dot($a, $b)

tune!(suite)
results = run(suite)
judge(minimum(results["naive"]), minimum(results["blas"]))

Profile: Finding Bottlenecks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
using Profile

function heavy_computation(n::Int)
    result = zeros(n, n)
    for i in 1:n, j in 1:n
        result[i, j] = sin(i * j / n)
    end
    return result
end

# Profile 100 iterations
@profile for _ in 1:100
    heavy_computation(500)
end

# Text profile: call counts per line
Profile.print()

# Flamegraph with ProfileView.jl
using ProfileView
ProfileView.view()  # Opens interactive flamegraph

The Scientific Ecosystem

LinearAlgebra (stdlib)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using LinearAlgebra

# Matrix operations
A = [1.0 2.0 3.0; 4.0 5.0 6.0; 7.0 8.0 10.0]
b = [1.0, 2.0, 3.0]

# Basic operations
A'           # Adjoint (transpose for real matrices)
tr(A)        # Trace
det(A)       # Determinant
inv(A)       # Inverse (expensive — usually solve instead)
norm(b)      # 2-norm
norm(A, 2)   # Matrix 2-norm (largest singular value)
rank(A)      # Matrix rank

# Solve Ax = b (better than inv(A) * b)
x = A \ b
@show norm(A * x - b)   # should be ~machine epsilon

# Factorizations
F = lu(A)    # LU decomposition
F.L          # Lower triangular
F.U          # Upper triangular
F.p          # Permutation vector

# Solve multiple right-hand sides efficiently
B = rand(3, 5)
X = F \ B    # Uses precomputed factorization

# QR decomposition
Q, R = qr(A)
norm(Q * R - A)   # ≈ 0

# SVD
U, S, V = svd(A)
norm(U * Diagonal(S) * V' - A)   # ≈ 0

# Eigendecomposition
vals, vecs = eigen(A)
# Verify: A * vecs[:, 1] ≈ vals[1] * vecs[:, 1]

# Cholesky (for symmetric positive definite matrices)
Σ = [4.0 2.0; 2.0 3.0]   # covariance matrix
C = cholesky(Σ)
C.L * C.L'   # ≈ Σ

# BLAS operations (direct BLAS calls for maximum performance)
using LinearAlgebra.BLAS

# DGEMM: C = α * A * B + β * C
A_big = rand(1000, 1000)
B_big = rand(1000, 1000)
C_big = zeros(1000, 1000)
BLAS.gemm!('N', 'N', 1.0, A_big, B_big, 0.0, C_big)

# Set number of BLAS threads
BLAS.set_num_threads(8)
BLAS.get_num_threads()

# Specialized matrix types — Julia uses dispatch to pick optimal algorithms
D = Diagonal([1.0, 2.0, 3.0])        # Diagonal matrix
T = Tridiagonal([1,1,1], [4,4,4,4], [1,1,1])  # Tridiagonal
S = Symmetric(A + A')                 # Symmetric (uses symmetric algorithms)
H = Hermitian(A + A')                 # Hermitian (complex matrices)

Plots.jl and Makie.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using Plots

# Backend selection
# gr()       — GR backend, fast, good for exploration (default)
# pyplot()   — Matplotlib, familiar to Python users
# plotlyjs() — Interactive in browser
# pgfplotsx() — LaTeX-quality for publications

# Basic plotting
x = range(0, 2π, length=200)
y = sin.(x)   # .() broadcasts over the array

plot(x, y, label="sin(x)", linewidth=2, color=:blue)
plot!(x, cos.(x), label="cos(x)", linewidth=2, color=:red, linestyle=:dash)
xlabel!("x")
ylabel!("f(x)")
title!("Trigonometric Functions")
savefig("trig.png")

# Subplots
p1 = plot(x, sin.(x), title="sin(x)", legend=false)
p2 = plot(x, cos.(x), title="cos(x)", legend=false)
p3 = plot(x, sin.(x) .* exp.(-x/4), title="Damped sin", legend=false)
p4 = scatter(sin.(x), cos.(x), title="Unit Circle", aspect_ratio=:equal, legend=false)

plot(p1, p2, p3, p4, layout=(2,2), size=(900, 600))
savefig("subplot_demo.png")

# Contour and surface plots
f(x, y) = sin(x) * cos(y)
xs = range(-π, π, length=100)
ys = range(-π, π, length=100)
z  = [f(x, y) for y in ys, x in xs]

contourf(xs, ys, z, levels=20, color=:viridis)
surface(xs, ys, z, xlabel="x", ylabel="y", zlabel="f(x,y)")

# CairoMakie for publication-quality figures
using CairoMakie

fig = Figure(resolution=(800, 600), fontsize=14)
ax = Axis(fig[1, 1],
    xlabel="Time (s)",
    ylabel="Amplitude",
    title="Signal Processing",
    xgridvisible=false)

t = LinRange(0, 10, 1000)
signal = @. sin(2π * 2 * t) + 0.3 * sin(2π * 5 * t)
noise  = 0.1 * randn(length(t))

lines!(ax, t, signal, color=:steelblue, linewidth=1.5, label="Signal")
lines!(ax, t, signal + noise, color=(:red, 0.4), linewidth=0.8, label="Noisy")
axislegend(ax)

save("publication_figure.pdf", fig)  # Vector PDF, LaTeX-ready

DataFrames.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
using DataFrames, CSV, Statistics

# Create a DataFrame
df = DataFrame(
    id       = 1:1000,
    name     = ["Person_$i" for i in 1:1000],
    age      = rand(18:80, 1000),
    salary   = round.(rand(30000:200000, 1000) ./ 1000) .* 1000,
    dept     = rand(["Engineering", "Sales", "HR", "Finance"], 1000),
    active   = rand(Bool, 1000),
)

# Load from CSV
df = CSV.read("employees.csv", DataFrame)

# Basic exploration
size(df)           # (1000, 6)
names(df)          # column names
describe(df)       # summary statistics
first(df, 5)       # head
last(df, 5)        # tail

# Selecting columns
df.salary          # column as Vector
df[!, :salary]     # same (! means copy not needed)
df[:, :salary]     # makes a copy
df[!, [:name, :salary]]   # multiple columns

# Filtering rows
subset(df, :age => ByRow(>(30)))          # age > 30
filter(:dept => ==("Engineering"), df)    # Engineering only
df[df.active .& (df.salary .> 100_000), :]  # Boolean indexing

# Transformations with transform
transform(df,
    :salary => (s -> s ./ 1000) => :salary_k,   # salary in thousands
    :age => ByRow(x -> x >= 65 ? "senior" : "regular") => :age_group
)

# GroupBy and aggregation
gdf = groupby(df, :dept)

# combine: apply aggregation functions to each group
summary = combine(gdf,
    :salary => mean => :mean_salary,
    :salary => std  => :std_salary,
    :salary => minimum => :min_salary,
    :salary => maximum => :max_salary,
    nrow => :headcount,
    :active => sum => :active_count,
)

# Sort
sort!(summary, :mean_salary, rev=true)

# Joins (similar to SQL)
managers = DataFrame(
    dept = ["Engineering", "Sales", "HR"],
    manager = ["Alice", "Bob", "Carol"]
)

# Inner join
innerjoin(df, managers, on=:dept)

# Left join — keeps all rows from left, fills missing for unmatched
leftjoin(df, managers, on=:dept)

# Anti-join — rows in left NOT in right
antijoin(df, managers, on=:dept)

# Reshape: wide to long and back
using DataFrames: stack, unstack

# Stack (wide → long / melt)
wide = DataFrame(id=1:5, q1=rand(5), q2=rand(5), q3=rand(5))
long = stack(wide, [:q1, :q2, :q3], variable_name=:quarter, value_name=:value)

# Unstack (long → wide / pivot)
wide_again = unstack(long, :id, :quarter, :value)

# Missing data handling
df_with_missing = DataFrame(x=[1, missing, 3, missing, 5], y=[1.0, 2.0, missing, 4.0, 5.0])
dropmissing(df_with_missing)          # drop rows with any missing
dropmissing(df_with_missing, :x)      # drop only where x is missing
coalesce.(df_with_missing.x, 0)       # replace missing with 0

DifferentialEquations.jl: The Flagship Library

DifferentialEquations.jl is arguably Julia’s most impressive achievement — a unified interface to solve ODEs, SDEs, DAEs, PDEs, delay differential equations, and more, with a solver library that substantially exceeds what SciPy offers.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
using DifferentialEquations, Plots

# --- Ordinary Differential Equations ---

# The Lorenz system: classic chaotic ODE
# dx/dt = σ(y - x)
# dy/dt = x(ρ - z) - y
# dz/dt = xy - βz

function lorenz!(du, u, p, t)
    σ, ρ, β = p
    du[1] = σ * (u[2] - u[1])
    du[2] = u[1] *- u[3]) - u[2]
    du[3] = u[1] * u[2] - β * u[3]
end

u0 = [1.0, 0.0, 0.0]
tspan = (0.0, 100.0)
p = (10.0, 28.0, 8/3)

prob = ODEProblem(lorenz!, u0, tspan, p)

# Tsit5: 4th/5th order Runge-Kutta, good default for non-stiff ODEs
sol = solve(prob, Tsit5(), reltol=1e-8, abstol=1e-8)

# Solution interpolates — access any time point
sol(50.0)       # state at t=50
sol.t           # time points chosen by adaptive stepper
sol.u           # states at those time points

# Plot directly
plot(sol, idxs=(1, 2, 3), title="Lorenz Attractor")

# --- Stiff ODEs ---

# Robertson chemical kinetics — a classic stiff problem
function robertson!(du, u, p, t)
    k1, k2, k3 = p
    du[1] = -k1 * u[1] + k3 * u[2] * u[3]
    du[2] =  k1 * u[1] - k2 * u[2]^2 - k3 * u[2] * u[3]
    du[3] =  k2 * u[2]^2
end

u0 = [1.0, 0.0, 0.0]
tspan = (0.0, 1e11)
p = (0.04, 3e7, 1e4)

prob_stiff = ODEProblem(robertson!, u0, tspan, p)

# Rodas5: stiff solver, much faster than Tsit5 on stiff problems
sol_stiff = solve(prob_stiff, Rodas5(), reltol=1e-8, abstol=1e-8)

# --- Stochastic Differential Equations (SDEs) ---

# Geometric Brownian Motion: dS = μS dt + σS dW
function gbm_drift!(du, u, p, t)
    μ, σ = p
    du[1] = μ * u[1]
end

function gbm_diffusion!(du, u, p, t)
    μ, σ = p
    du[1] = σ * u[1]
end

u0 = [100.0]   # Initial price
tspan = (0.0, 1.0)   # 1 year
p = (0.05, 0.20)     # 5% drift, 20% volatility

prob_sde = SDEProblem(gbm_drift!, gbm_diffusion!, u0, tspan, p)

# Simulate 1000 paths
ensembleprob = EnsembleProblem(prob_sde)
sol_ensemble = solve(ensembleprob, EnsembleThreads(), trajectories=1000)

# Summary statistics across paths
using DifferentialEquations: EnsembleSummary
summary = EnsembleSummary(sol_ensemble, 0:0.01:1.0)
plot(summary, title="GBM: 1000 Paths (5th-95th percentile)")

# --- Parameter Estimation ---

using DifferentialEquations, Optimization, OptimizationOptimJL

# Generate synthetic data
true_params = [1.5, 1.0, 3.0, 1.0]
function lotka_volterra!(du, u, p, t)
    α, β, γ, δ = p
    du[1] =  α * u[1] - β * u[1] * u[2]
    du[2] = -γ * u[2] + δ * u[1] * u[2]
end

prob_lv = ODEProblem(lotka_volterra!, [1.0, 1.0], (0.0, 10.0), true_params)
sol_true = solve(prob_lv, Tsit5())
t_obs = 0.0:0.5:10.0
data = Array(sol_true(t_obs)) + 0.1 * randn(2, length(t_obs))

# Loss function: mean squared error between model and data
function loss(params, _)
    prob = ODEProblem(lotka_volterra!, [1.0, 1.0], (0.0, 10.0), params)
    sol = solve(prob, Tsit5(), saveat=0.5)
    if sol.retcode != ReturnCode.Success
        return Inf, sol
    end
    pred = Array(sol)
    l = sum(abs2, data .- pred)
    return l, sol
end

# Optimize with BFGS
optfunc = OptimizationFunction(loss, Optimization.AutoZygote())
optprob = OptimizationProblem(optfunc, [1.0, 1.0, 2.0, 1.5])
result = solve(optprob, BFGS())
println("Recovered parameters: ", result.u)
println("True parameters:      ", true_params)

Flux.jl: Machine Learning in Pure Julia

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using Flux
using Flux: DataLoader, train!
using Statistics

# Flux.jl: ML framework entirely in Julia — no Python backend
# This matters: you can differentiate through your ODE solver,
# your custom loss, and your model all in the same AD pass.

# --- Simple feedforward network ---
model = Chain(
    Dense(784 => 256, relu),
    Dropout(0.3),
    Dense(256 => 128, relu),
    Dropout(0.2),
    Dense(128 => 10),
)

# Flux uses Zygote.jl for automatic differentiation
params = Flux.params(model)

# Loss
loss_fn(x, y) = Flux.crossentropy(softmax(model(x)), y)

# Optimizer
opt = Adam(0.001)

# Training loop
function train_epoch!(model, loader, opt)
    total_loss = 0.0
    n_batches = 0
    for (x, y) in loader
        gs = gradient(params) do
            loss_fn(x, y)
        end
        Flux.update!(opt, params, gs)
        total_loss += loss_fn(x, y)
        n_batches += 1
    end
    return total_loss / n_batches
end

# --- Neural ODE: combine DiffEq and ML ---
using DifferentialEquations, Flux, SciMLSensitivity

# A neural network as the right-hand side of an ODE
neural_ode_rhs = Chain(Dense(2, 32, tanh), Dense(32, 2))

function neural_ode_forward(u0, p, tspan)
    function dudt!(du, u, p, t)
        du .= neural_ode_rhs(u)
    end
    prob = ODEProblem(dudt!, u0, tspan)
    return solve(prob, Tsit5(), saveat=0.1, sensealg=BacksolveAdjoint())
end

# Gradient flows through the ODE solver — this is what makes Julia special
# In Python, you'd need a specialized library (torchdiffeq); in Julia, it's built in.

# --- Lux.jl: explicit state, better for research ---
using Lux, Random, Zygote

rng = Random.default_rng()

# Lux separates model structure, parameters, and state explicitly
# (like PyTorch's functional API, or Flax in JAX)
model_lux = Lux.Chain(
    Lux.Dense(10 => 32, relu),
    Lux.Dense(32 => 1),
)

ps, st = Lux.setup(rng, model_lux)

x_input = randn(Float32, 10, 32)  # 10 features, batch of 32

# Forward pass: explicit params and state
y_pred, st_new = model_lux(x_input, ps, st)

# Gradient w.r.t. parameters
loss_lux(ps, st, x, y) = begin
    y_pred, st_new = model_lux(x, ps, st)
    return mean(abs2, y_pred .- y), st_new
end

gs = gradient(p -> first(loss_lux(p, st, x_input, zeros(Float32, 1, 32))), ps)

Turing.jl: Probabilistic Programming

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using Turing, StatsPlots, Distributions

# Turing.jl: PPL built on Julia's multiple dispatch and AD
# Competes with Stan but in pure Julia (no external DSL)

# --- Bayesian Linear Regression ---
@model function bayesian_regression(x, y)
    # Priors
    α  ~ Normal(0, 10)           # intercept
    β  ~ Normal(0, 10)           # slope
    σ  ~ Exponential(1)          # noise standard deviation

    # Likelihood
    for i in eachindex(y)
        y[i] ~ Normal(α + β * x[i], σ)
    end
end

# Generate synthetic data
true_α, true_β, true_σ = 2.0, 3.0, 0.5
x_data = randn(100)
y_data = true_α .+ true_β .* x_data .+ true_σ .* randn(100)

# Sample with NUTS (No-U-Turn Sampler — same as Stan's default)
model = bayesian_regression(x_data, y_data)
chain = sample(model, NUTS(0.65), 2000; progress=false)

# Summary statistics
summarize(chain)
# parameters    mean       std    naive_se  mcse      ess   r̂
#   α           2.02      0.05     0.001    0.001   1752   1.00
#   β           2.98      0.05     0.001    0.001   1823   1.00
#   σ           0.51      0.04     0.001    0.001   1891   1.00

plot(chain)   # Trace plots and marginal distributions

# --- Hierarchical Model ---
@model function hierarchical_schools(J, sigma, y)
    μ ~ Normal(0, 10)
    τ ~ truncated(Cauchy(0, 2.5), lower=0)
    θ ~ filldist(Normal(μ, τ), J)

    for i in 1:J
        y[i] ~ Normal(θ[i], sigma[i])
    end
end

# Eight Schools data (classic hierarchical example)
J = 8
sigma = [15, 10, 16, 11, 9, 11, 10, 18]
y_schools = [28, 8, -3, 7, -1, 1, 18, 12]

chain_schools = sample(
    hierarchical_schools(J, sigma, y_schools),
    NUTS(0.65),
    MCMCThreads(),  # parallel chains using Julia threads
    1000, 4         # 1000 samples, 4 chains
)

# --- Variational Inference (faster for large models) ---
q = vi(model, ADVI(10, 1000))  # 10 samples, 1000 iterations
posterior_samples = rand(q, 1000)

Symbolics.jl: Symbolic Mathematics

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
using Symbolics

# Declare symbolic variables
@variables x y z t

# Symbolic expressions
expr = sin(x)^2 + cos(x)^2
simplify(expr)   # → 1

poly = (x + y)^3
expand(poly)     # → x³ + 3x²y + 3xy² + y³

# Differentiation
D = Differential(x)

f = x^3 + 2*x^2 + 5*x + 1
D(f)           # symbolic: 3x² + 4x + 5
expand_derivatives(D(f))  # → 3(x^2) + 4x + 5

# Higher-order derivatives
D2 = Differential(x)^2
expand_derivatives(D2(f))  # → 6x + 4

# Partial derivatives
g = x^2 * y + sin(x * y)
expand_derivatives(Differential(x)(g))  # → 2xy + y*cos(x*y)
expand_derivatives(Differential(y)(g))  # → x^2 + x*cos(x*y)

# Jacobian and Hessian
@variables q[1:3]   # vector of symbolic variables
f_vec = [q[1]^2 + q[2], q[2]*q[3], sin(q[1])]

jac = Symbolics.jacobian(f_vec, collect(q))
# 3×3 symbolic Jacobian matrix

hess = Symbolics.hessian(q[1]^2 + q[2]^2 + q[3]^2, collect(q))
# 3×3 symbolic Hessian (diagonal: [2, 2, 2])

# Code generation: symbolic → optimized Julia code
fn, fn! = build_function(f_vec, collect(q))
# fn is a callable function — fast runtime code generated from symbolic expression

# Solve symbolic equations
@variables a b c
roots = Symbolics.solve_for([a*x^2 + b*x + c ~ 0], [x])

# Integration with ModelingToolkit.jl for physical systems
using ModelingToolkit

@variables t
D_t = Differential(t)
@variables x(t) y(t)   # time-varying symbolic variables

# Define an ODE system symbolically
eqs = [
    D_t(x) ~ -x + y,
    D_t(y) ~ x - y^2,
]

@named sys = ODESystem(eqs, t)
sys_simplified = structural_simplify(sys)

# Convert to DifferentialEquations.jl problem automatically
prob_sym = ODEProblem(sys_simplified, [x => 1.0, y => 0.5], (0.0, 10.0))
sol_sym = solve(prob_sym, Tsit5())

Parallel and Distributed Computing

Multi-Threading with @threads

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Base.Threads
println("Threads: ", nthreads())   # Set with JULIA_NUM_THREADS env var

# Start Julia with: julia --threads 8
# or: JULIA_NUM_THREADS=8 julia

# Simple parallel loop
function parallel_sum_squares(n::Int)
    result = Atomic{Float64}(0.0)
    @threads for i in 1:n
        atomic_add!(result, Float64(i)^2)
    end
    return result[]
end

# Better pattern: thread-local accumulators (avoid atomic contention)
function parallel_sum_squares_fast(n::Int)
    partial = zeros(Float64, nthreads())
    @threads for i in 1:n
        partial[threadid()] += Float64(i)^2
    end
    return sum(partial)
end

# @threads on realistic work: parallel matrix row operations
function parallel_normalize_rows!(A::Matrix{Float64})
    @threads for i in axes(A, 1)
        row_norm = norm(view(A, i, :))
        if row_norm > 0
            A[i, :] ./= row_norm
        end
    end
    return A
end

Task-Based Parallelism with @spawn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# @spawn: create asynchronous tasks, more flexible than @threads
function parallel_map(f, items)
    tasks = [Threads.@spawn f(item) for item in items]
    return [fetch(t) for t in tasks]
end

# Useful for IO-heavy or heterogeneous workloads
function fetch_and_process(urls::Vector{String})
    tasks = map(urls) do url
        Threads.@spawn begin
            # Each URL fetched concurrently on a thread pool
            data = download_data(url)   # IO-bound work
            process(data)               # CPU-bound work
        end
    end
    return fetch.(tasks)
end

# Recursive parallel algorithms
function parallel_fib(n::Int)
    n <= 20 && return fib_sequential(n)   # Don't spawn tiny tasks
    t = Threads.@spawn parallel_fib(n - 1)
    r = parallel_fib(n - 2)
    return fetch(t) + r
end

Distributed Computing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using Distributed

# Add worker processes
addprocs(4)   # 4 additional processes (workers)
# addprocs([("worker1.example.com", 2), ("worker2.example.com", 4)])  # Remote workers
nprocs()      # total (1 main + 4 workers)
nworkers()    # 4

# Load code on all workers
@everywhere using LinearAlgebra

# @everywhere: run on all processes
@everywhere function expensive_work(x::Float64)
    # Simulate heavy computation
    return sum(sin(x * k) for k in 1:10_000)
end

# pmap: parallel map (auto-distributes work)
inputs = rand(1000)
results = pmap(expensive_work, inputs)

# @distributed: parallel for loop with reduction
total = @distributed (+) for i in 1:1_000_000
    rand()^2
end
# Approximates π/4 via Monte Carlo

# SharedArray: shared memory between processes on same machine
using SharedArrays
A = SharedArray{Float64}(1000, 1000)
@distributed for i in 1:1000
    A[i, :] = rand(1000)
end

GPU Computing with CUDA.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using CUDA

# Check GPU
CUDA.versioninfo()
device()

# Move arrays to GPU — operations are JIT-compiled for GPU
x_cpu = rand(Float32, 10_000_000)
x_gpu = CuArray(x_cpu)   # transfer to GPU memory

# Standard Julia operations work on GPU arrays
y_gpu = x_gpu .^ 2 + 2 .* x_gpu   # element-wise, runs on GPU
z_gpu = sum(y_gpu)                 # GPU reduction

# Move back
y_cpu = Array(y_gpu)

# GPU matrix multiplication (CUBLAS)
A_gpu = CUDA.randn(Float32, 1000, 1000)
B_gpu = CUDA.randn(Float32, 1000, 1000)
C_gpu = A_gpu * B_gpu   # Uses CUBLAS SGEMM automatically

# Write custom GPU kernels in Julia
function vector_add_kernel!(c, a, b)
    i = (blockIdx().x - 1) * blockDim().x + threadIdx().x
    if i <= length(c)
        c[i] = a[i] + b[i]
    end
    return nothing
end

n = 10_000
a_gpu = CUDA.ones(Float32, n)
b_gpu = CUDA.ones(Float32, n) .* 2
c_gpu = CUDA.zeros(Float32, n)

threads = 256
blocks = cld(n, threads)   # ceil(n / threads)
@cuda threads=threads blocks=blocks vector_add_kernel!(c_gpu, a_gpu, b_gpu)
CUDA.synchronize()
all(Array(c_gpu) .== 3.0f0)   # true

# @turbo: CPU SIMD vectorization with LoopVectorization.jl
using LoopVectorization

function dot_turbo(a::Vector{Float64}, b::Vector{Float64})
    s = 0.0
    @turbo for i in eachindex(a)
        s += a[i] * b[i]
    end
    return s
end

# @turbo often approaches or exceeds BLAS for simple operations
a = rand(10_000); b = rand(10_000)
@btime dot_turbo($a, $b)   # often < 5μs on modern hardware

@simd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# @simd: hint to compiler that loop iterations are independent, enable SIMD
function sum_simd(v::Vector{Float64})
    s = 0.0
    @simd for x in v
        s += x
    end
    return s
end

# @inbounds + @simd: disable bounds checking and enable SIMD together
function inner_product_fast(a::Vector{Float64}, b::Vector{Float64})
    s = 0.0
    @inbounds @simd for i in eachindex(a)
        s += a[i] * b[i]
    end
    return s
end

Interoperability

Calling Python with PythonCall.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using PythonCall

# PythonCall.jl is the modern Python interop (replaces PyCall.jl)
# Uses a separate Python environment managed by CondaPkg.jl

np = pyimport("numpy")
pd = pyimport("pandas")

# NumPy arrays
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
julia_arr = pyconvert(Vector{Float64}, arr)

# Go the other way: Julia → Python
jl_data = [1.0, 2.0, 3.0]
py_data = Py(jl_data)   # wraps without copy when types are compatible

# Call pandas
df_py = pd.DataFrame(pydict(Dict("a" => [1,2,3], "b" => [4,5,6])))
py_result = df_py.groupby("a").sum()

# Use scikit-learn from Julia
sklearn = pyimport("sklearn.linear_model")
LinearRegression = sklearn.LinearRegression

X = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
y = np.array([1.0, 2.0, 3.0])

model_py = LinearRegression()
model_py.fit(X, y)
println("Coefficients: ", pyconvert(Vector{Float64}, model_py.coef_))

# Use matplotlib for plotting in Julia notebooks
plt = pyimport("matplotlib.pyplot")
x_py = np.linspace(0, 2*3.14159, 100)
plt.plot(x_py, np.sin(x_py))
plt.savefig("from_julia.png")
plt.close()

Calling R with RCall.jl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using RCall

# R expressions
R"summary(c(1, 2, 3, 4, 5))"

# Transfer data
x = randn(100)
@rput x   # send Julia variable to R
R"hist(x, main='Julia data in R')"
R"shapiro_result <- shapiro.test(x)"
@rget shapiro_result   # get R result back

# Run R scripts
R"""
library(ggplot2)
data(mtcars)
p <- ggplot(mtcars, aes(x=wt, y=mpg)) +
    geom_point() +
    geom_smooth(method='lm') +
    labs(title='Weight vs MPG')
ggsave('ggplot_from_julia.png', p)
"""

# Use R packages not available in Julia
R"""
library(forecast)
data <- ts($x, frequency=12)
model <- auto.arima(data)
forecast_result <- forecast(model, h=24)
"""
@rget forecast_result

Calling C and Fortran with ccall

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# ccall has zero overhead — no marshaling, no FFI wrapper layer
# The JIT sees through ccall and generates the same code as a direct call

# Call C standard library
# ccall((function_name, library), return_type, (arg_types...,), args...)

# strlen from libc
s = "Hello, World!"
len = ccall(:strlen, Csize_t, (Cstring,), s)
println("Length: $len")   # 13

# Math functions (often already inlined by Julia, but demonstrates the syntax)
val = ccall((:sin, "libm"), Cdouble, (Cdouble,), 1.5)

# Call custom C function
# Suppose you have: double dot_product(double *a, double *b, int n);
a = [1.0, 2.0, 3.0]
b = [4.0, 5.0, 6.0]
n = length(a)

result = ccall(
    (:dot_product, "libmymath"),  # function name and library
    Float64,                       # return type
    (Ptr{Float64}, Ptr{Float64}, Cint),  # argument types
    a, b, n                        # arguments (Julia arrays pass as pointers)
)

# Call Fortran (Fortran mangles names with underscore)
# DGEMM from BLAS (Fortran subroutine — no return value → Cvoid)
using LinearAlgebra.BLAS: @blasfunc

# Julia's LinearAlgebra calls BLAS via ccall internally.
# Example of what that looks like:
function my_dgemm!(C, A, B, α=1.0, β=0.0)
    m, k = size(A)
    k2, n = size(B)
    @assert k == k2
    @assert size(C) == (m, n)

    ccall((@blasfunc(dgemm_), BLAS.libblastrampoline),
        Cvoid,
        (Ref{UInt8}, Ref{UInt8}, Ref{BlasInt}, Ref{BlasInt}, Ref{BlasInt},
         Ref{Float64}, Ptr{Float64}, Ref{BlasInt}, Ptr{Float64}, Ref{BlasInt},
         Ref{Float64}, Ptr{Float64}, Ref{BlasInt}),
        'N', 'N', m, n, k, α, A, m, B, k, β, C, m
    )
    return C
end

CxxWrap.jl for C++

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# C++ interop via CxxWrap.jl (requires a C++ wrapper module)
# In C++:
# #include <jlcxx/jlcxx.hpp>
# JLCXX_MODULE define_julia_module(jlcxx::Module& mod) {
#     mod.add_type<MyClass>("MyClass")
#        .constructor<int, double>()
#        .method("compute", &MyClass::compute);
# }

using CxxWrap
@wrapmodule(() -> "libmymodule")
@initcxx

obj = MyClass(42, 3.14)
result = compute(obj)

Package Management

Julia’s package manager (Pkg.jl) is baked into the language and is one of its genuinely superior aspects compared to Python’s ecosystem.

The Pkg REPL Mode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# In the Julia REPL, press ] to enter Pkg mode

# Add packages
pkg> add DataFrames CSV Plots BenchmarkTools

# Add a specific version
pkg> add DataFrames@1.6.0

# Add from GitHub (unregistered package)
pkg> add https://github.com/username/MyPackage.jl

# Add a local path
pkg> add /path/to/MyPackage

# Remove a package
pkg> remove Plots

# Update all packages
pkg> update

# Update a specific package
pkg> update DataFrames

# Show package status
pkg> status
# Project lunarOps_demo v0.1.0
#   [a93c6f00] DataFrames v1.6.1
#   [91a5bcdd] Plots v1.40.0
#   [6f49c342] CSV v0.10.12

# Show outdated packages
pkg> outdated

# Pin a package version (prevent updates)
pkg> pin DataFrames

# Resolve and precompile all dependencies
pkg> precompile

Environments: Project.toml and Manifest.toml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Activate an environment (creates if not exists)
using Pkg
Pkg.activate("./MyProject")
# or from the REPL: pkg> activate ./MyProject

# Project.toml: human-edited, version constraints
# [deps]
# DataFrames = "a93c6f00-e57d-5684-b890-1f30009b9b0e"
# CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
#
# [compat]
# julia = "1.9"
# DataFrames = "1"
# CSV = "0.10"

# Manifest.toml: auto-generated, exact versions of all deps including transitive
# Never edit Manifest.toml by hand.
# Commit both for reproducible research; commit only Project.toml for libraries.

# Instantiate from Project.toml (recreate exact environment from Manifest.toml)
Pkg.instantiate()

# Example: set up a project from scratch
Pkg.activate("my_analysis")
Pkg.add(["DataFrames", "CSV", "Plots", "Statistics", "BenchmarkTools"])
Pkg.status()

Creating Packages

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Generate package structure
julia -e 'using Pkg; Pkg.generate("MyPackage")'

# Creates:
# MyPackage/
# ├── Project.toml
# └── src/
#     └── MyPackage.jl

# Better: use PkgTemplates.jl for a full setup
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using PkgTemplates

t = Template(
    user="yourusername",
    license="MIT",
    plugins=[
        Git(ssh=true),
        GitHubActions(; x86=false),
        Codecov(),
        Documenter{GitHubActions}(),
        Tests(; project=true),
    ]
)

t("MyPackage")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# src/MyPackage.jl
module MyPackage

export greet, PhysicsModel, simulate

include("physics.jl")
include("io.jl")

function greet(name::String)
    println("Hello from MyPackage, $name!")
end

end  # module

# test/runtests.jl
using MyPackage
using Test

@testset "MyPackage" begin
    @testset "greet" begin
        @test greet("world") === nothing   # returns nothing, doesn't throw
    end

    @testset "PhysicsModel" begin
        m = PhysicsModel(mass=1.0, stiffness=10.0)
        sol = simulate(m, tspan=(0.0, 10.0))
        @test length(sol.t) > 1
        @test all(isfinite, sol.u)
    end
end

# Run tests
# pkg> test MyPackage

Registries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# The General registry is the default — contains thousands of packages
# Check what's available at: https://juliahub.com/ui/Packages

# Add a private registry (for internal packages)
pkg> registry add https://github.com/myorg/MyRegistry

# Register your own package
# 1. Create the package with proper Project.toml
# 2. Tag a version: git tag v0.1.0
# 3. Use Registrator.jl or JuliaHub to register
# 4. Or for local use: keep in the local path, addprefs

The Time to First Plot Problem

Julia’s most notorious woe is latency on first use. When you using Plots; plot(x, y) for the first time in a session, you wait. On Julia 1.9, this was often 15–30 seconds. On Julia 1.11, it’s down to 3–8 seconds for typical packages — still a real cost.

What Causes It

The latency is structural. Julia has to:

  1. Load the package’s source code
  2. Compile all the methods called during using (the __init__ functions)
  3. For plot(), compile the entire plotting pipeline for your specific argument types

This is the price of Julia’s JIT model: specialization happens late, at the call site, with exact types. The compiler generates fast code, but it has to generate it.

What’s Improved

Julia 1.9 introduced native code caching: precompiled code is saved as .ji files in ~/.julia/compiled/. On first use, these files are loaded rather than recompiled, which dramatically reduces startup time for well-precompiled packages.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Check precompilation cache status
using Pkg
Pkg.precompile()   # Force precompilation of all installed packages

# Packages can declare precompile workloads with PrecompileTools.jl
# (good packages already do this; check their source)

using PrecompileTools

@setup_workload begin
    # Code here runs during package precompilation, not at load time
    x = collect(1.0:10.0)
    y = sin.(x)

    @compile_workload begin
        # This call is compiled and cached at precompile time
        plot(x, y)       # ← "time to first plot" is now paid during ]precompile
    end
end

PackageCompiler.jl: Sysimages and Apps

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using PackageCompiler

# Create a custom system image that bakes in precompiled code
# This eliminates startup latency entirely for the included packages

create_sysimage(
    ["DataFrames", "CSV", "Plots", "DifferentialEquations"],
    sysimage_path = "my_sysimage.so",
    precompile_execution_file = "warmup.jl"  # Run this to determine what to bake in
)

# warmup.jl: a script that exercises the code you want fast
# It runs during sysimage creation, and the compiled code is baked in.
# using DataFrames; df = DataFrame(a=1:100, b=randn(100)); combine(df, :a => sum)
# using Plots; x = 1:10; plot(x, sin.(x))

# Use the sysimage
# julia --sysimage my_sysimage.so my_script.jl

# Create a deployable app (no Julia installation needed on target machine)
create_app(
    "MyProject",           # directory with Project.toml and src/
    "build/MyApp",         # output directory
    precompile_execution_file = "warmup.jl",
    include_lazy_artifacts = true,
)
# Creates: build/MyApp/bin/MyApp — a self-contained binary

Current Reality in Julia 1.11

Package Load Times (measured, Julia 1.11, Apple M2 Pro):

using DataFrames                    0.9s  (good)
using Plots                         2.1s  (acceptable)
using DifferentialEquations         4.3s  (noticeable)
using Flux                          5.8s  (annoying for notebooks)

First plot (Plots.jl, post-load):   3.4s  (the infamous TTFP)
Second plot:                        0.04s (fast)

The trajectory is clear: Julia 1.6 was painful, 1.9 was much better, 1.11 is acceptable for workflows where you keep sessions alive. For scripts that start and exit in milliseconds, Python still wins.


Julia vs Python: An Honest Comparison

This is the comparison experienced engineers actually need. Here’s the honest version.

Numerical Performance

Julia wins decisively for numerical code. Not 10–20% faster — orders of magnitude on the right workloads.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Julia: type-stable tight loop
function mandelbrot_count(c::ComplexF64, max_iter::Int)::Int
    z = zero(ComplexF64)
    for i in 1:max_iter
        z = z^2 + c
        abs2(z) > 4.0 && return i
    end
    return max_iter
end

function mandelbrot_julia(n::Int, max_iter::Int=100)
    xs = LinRange(-2.5, 1.0, n)
    ys = LinRange(-1.25, 1.25, n)
    result = Matrix{Int}(undef, n, n)
    @threads for j in 1:n, i in 1:n
        result[i, j] = mandelbrot_count(complex(xs[i], ys[j]), max_iter)
    end
    return result
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Python: equivalent NumPy/Numba version
import numpy as np

def mandelbrot_numpy(n, max_iter=100):
    xs = np.linspace(-2.5, 1.0, n)
    ys = np.linspace(-1.25, 1.25, n)
    X, Y = np.meshgrid(xs, ys)
    C = X + 1j * Y
    Z = np.zeros_like(C)
    result = np.zeros(C.shape, dtype=int)
    for i in range(max_iter):
        mask = np.abs(Z) <= 2
        Z[mask] = Z[mask]**2 + C[mask]
        result[mask] += 1
    return result
# NumPy version is vectorized but still ~5–10x slower for n=1000
# Julia threaded version: ~40ms. NumPy: ~200ms. Pure Python: ~8000ms.

Benchmarks on typical scientific workloads (Julia 1.11 vs Python 3.12 + NumPy 2.0):

Workload Julia NumPy Numba (JIT)
Dense matrix multiply (1000×1000) ~equal ~equal ~equal (both use BLAS)
ODE solving (Lorenz, 1e6 steps) 1x 8x slower 2x slower
Monte Carlo (inner loop) 1x 15x slower 1.2x slower
Custom numerical kernel 1x 5–50x slower 1–2x slower
Symbolic differentiation 1x N/A N/A

The gap closes when NumPy vectorization covers everything — if your computation maps to BLAS or simple array ops, Python is fine. The gap is brutal when you need control flow in the hot path (ODE solvers, Monte Carlo, custom algorithms).

Startup Time

Python wins here. There’s no contest.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Python: ~50ms for basic script
time python -c "import numpy; print('hello')"
# real    0m0.089s

# Julia: 2–4 seconds for basic script (JIT warmup)
time julia -e "println(\"hello\")"
# real    0m2.341s

# Julia with sysimage: ~0.2s
time julia --sysimage my_sysimage.so -e "println(\"hello\")"
# real    0m0.198s

For scripts run repeatedly (batch jobs, CLI tools), Julia’s startup time is genuinely painful. The mitigations — sysimages, keeping sessions alive, julia --project with precompilation — all work but add friction.

Verdict: If you’re writing a tool that starts, does one thing, and exits, use Python or Go. If you’re running long sessions (simulations, interactive analysis), Julia’s startup cost is paid once.

Machine Learning Ecosystem

Python wins for ML deployment and tooling breadth. Julia wins for research where you need to differentiate through non-ML code.

Aspect Python Julia
Framework maturity PyTorch » Flux.jl Flux.jl is good but younger
Production deployment TorchServe, ONNX, TFLite Limited (use ONNX export)
Pre-trained models Hugging Face, thousands Growing, fewer
Differentiating through ODE torchdiffeq (awkward) Native, elegant
Neural ODE / physics ML External packages Built-in via SciML
GPU support Mature, CUDA & ROCm CUDA.jl excellent; ROCm experimental

If you’re training transformer models on large datasets and deploying to production, use Python. If you’re doing physics-informed ML, neural ODEs, or scientific ML where the model includes domain equations, Julia is a serious advantage.

Scientific Computing

Julia is competitive or winning for scientific computing specifically.

Domain Julia Python
ODE/PDE solving DifferentialEquations.jl (best-in-class) SciPy.integrate (slower, fewer solvers)
Probabilistic programming Turing.jl (excellent) Stan (faster sampler), PyMC (more users)
Symbolic math Symbolics.jl SymPy (broader, more stable)
Linear algebra On par with NumPy (both use BLAS) NumPy + SciPy
Statistics StatsBase.jl, reasonable StatsModels + SciPy + R via rpy2
Optimization JuMP.jl (industry-leading for MIP) scipy.optimize, CVXPY
Molecular dynamics Not mainstream yet OpenMM, GROMACS wrappers

JuMP.jl deserves special mention — it’s a domain-specific language for mathematical optimization embedded in Julia, and it’s considered superior to alternatives in other languages for defining and solving mixed-integer programs.

Parallelism

Julia wins on ease of parallelism.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Julia: parallelism is first-class
using Base.Threads

# This just works — no GIL, no multiprocessing overhead
@threads for i in 1:N
    results[i] = expensive_computation(data[i])
end

# Distributed across machines is one addprocs() call
using Distributed
addprocs(4)
@distributed for i in 1:N
    # runs on workers
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Python: GIL prevents true thread parallelism for CPU work
# Must use multiprocessing, which has significant overhead
from multiprocessing import Pool
with Pool(4) as p:
    results = p.map(expensive_computation, data)
# Good performance but: fork overhead, pickle serialization, memory copying

# Numba with parallel=True works for some cases
@nb.njit(parallel=True)
def parallel_loop(data):
    for i in nb.prange(len(data)):
        # only works if the kernel is Numba-compatible

The GIL is Python’s Achilles’ heel for scientific computing. Python 3.13+ has a free-threading mode (nogil), but ecosystem support is nascent. Julia doesn’t have this problem.

Package Ecosystem Breadth

Python wins, and it’s not close.

Python has ~500,000 packages on PyPI. The deep learning frameworks, cloud SDKs, web frameworks, DevOps tooling, data wrangling tools, and visualization libraries all live in Python. Julia’s ~10,000 registered packages cover scientific computing well but lack breadth in web, DevOps, cloud, business applications, and NLP tooling.

If your work involves anything outside scientific/numerical computing — API integrations, web scraping, database ORMs, PDF generation, business logic — Python has the package you need. Julia probably doesn’t.

Interactive Computing

Comparable. Both support Jupyter notebooks (via IJulia for Julia). Julia’s REPL is excellent. Pluto.jl is Julia’s reactive notebook environment (more like Observable) and is arguably nicer than Jupyter for exploratory work.

1
2
3
4
5
6
7
8
# IJulia: use Julia in Jupyter
# ]add IJulia
# using IJulia; notebook()

# Pluto.jl: reactive notebooks (cells update automatically when inputs change)
# ]add Pluto
using Pluto
Pluto.run()

Hiring and Community

Python wins by a large margin. There are orders of magnitude more Python developers than Julia developers. Julia is strong in academia, quantitative finance (Jane Street uses it heavily), and specialized scientific computing groups. But for most engineering teams, the hiring pool is the decisive factor.

The Summary Table

Dimension Julia Python Winner
Raw numerical performance Excellent Good (with NumPy/Numba) Julia
Startup time Slow (2–4s) Fast (50–100ms) Python
ML deployment and tooling Limited Industry standard Python
Scientific computing Best-in-class (DiffEq.jl) Good (SciPy) Julia
Ease of parallelism First-class, no GIL GIL workarounds needed Julia
Package ecosystem breadth ~10k packages ~500k packages Python
Interoperability Can call Python/R/C/Fortran Can call C/Fortran/R Comparable
Interactive analysis Pluto.jl / Jupyter Jupyter (more mature) Comparable
Type system Powerful parametric types Dynamic, gradual typing Julia (for perf)
Learning curve Steep (multiple dispatch) Moderate Python
Hiring pool Niche Enormous Python
Community size Growing, academic focus Massive, broad Python

When to Choose Julia

Use Julia when:

  • You are solving numerical problems where Python performance is genuinely insufficient and you don’t want a C extension
  • You are solving ODEs, PDEs, SDEs, or inverse problems (DifferentialEquations.jl is best-in-class)
  • You need physics-informed ML or neural ODEs — Julia’s AD system differentiates through everything
  • You are doing probabilistic programming and want to stay in one language
  • You have parallelism needs that Python’s GIL makes painful
  • You work in an environment where Julia is already used (quantitative finance, academic scientific computing, aerospace)
  • You want to write custom numerical algorithms that are both readable and fast

Stick with Python when:

  • You need deployment into production ML systems (PyTorch, ONNX, TFLite)
  • Your work involves packages without Julia equivalents (cloud SDKs, enterprise integrations, NLP tooling)
  • You run short scripts that start and exit (Julia’s startup cost will hurt)
  • You need to hire engineers and Julia expertise doesn’t exist in your candidate pool
  • You are early in a project and ecosystem breadth matters more than peak performance
  • You interact heavily with the Hugging Face ecosystem, LangChain, or similar Python-first tooling

The Learning Path

Week 1: Syntax and Fundamentals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Julia is syntactically close to Python/MATLAB but semantically different
# Key syntax to learn:

# Variables and types
x = 42          # Int64
y = 3.14        # Float64
z = "hello"     # String
b = true        # Bool
c = 1 + 2im     # Complex{Int64}

# Collections
v = [1, 2, 3, 4, 5]        # Vector{Int64} (1D array)
m = [1 2 3; 4 5 6; 7 8 9]  # 3×3 Matrix{Int64}
t = (1, "hello", 3.14)      # Tuple{Int64, String, Float64}
d = Dict("a" => 1, "b" => 2)

# Strings
s = "Hello, Julia!"
"Result: $(2 + 2)"   # string interpolation

# Control flow
if x > 0
    println("positive")
elseif x == 0
    println("zero")
else
    println("negative")
end

for i in 1:10
    print(i, " ")
end

while x > 0
    x -= 1
end

# Functions
function square(x)
    return x^2
end

square(x) = x^2           # One-line form
sq = x -> x^2             # Anonymous function

# Broadcasting: apply function element-wise with .
v = [1, 2, 3, 4, 5]
v .^ 2          # [1, 4, 9, 16, 25]
sqrt.(v)        # element-wise sqrt
sin.(v)         # element-wise sin
v .> 3          # [false, false, false, true, true]

Week 2: Types and Multiple Dispatch

Focus on understanding abstract types, the type hierarchy, and writing your first multi-method functions. Use @which and @code_warntype constantly.

Week 3–4: Performance

Learn type stability, read @code_typed output, profile with Profile.jl and BenchmarkTools.jl. Understand why @code_warntype shows red.

Month 2: The Scientific Ecosystem

Pick one of: DifferentialEquations.jl, Flux.jl, Turing.jl, or JuMP.jl — whichever matches your domain. Go deep on one package rather than surface-level on all.

Resources


Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# Types
typeof(x)          # Get type of x
isa(x, T)          # Check if x is of type T
x isa T            # Alternative syntax
T <: S             # T is subtype of S
supertype(T)       # Parent in type hierarchy
subtypes(T)        # Direct subtypes

# Introspection
@which f(x, y)           # Which method will be called
@code_warntype f(x, y)   # Type stability analysis (red = bad)
@code_lowered f(x, y)    # Julia IR
@code_typed f(x, y)      # Type-inferred IR
@code_llvm f(x, y)       # LLVM IR
@code_native f(x, y)     # Assembly
methods(f)               # All methods of f

# Performance
@time expr               # Wall time and allocations
@btime expr              # BenchmarkTools: accurate timing
@elapsed expr            # Returns elapsed seconds
@allocations expr        # Returns allocation count
@inbounds expr           # Disable bounds checking
@simd for ...            # SIMD hint
@turbo for ...           # LoopVectorization SIMD (requires pkg)
@threads for ...         # Multi-threading
@spawn task              # Spawn async task

# Common patterns
zeros(n)                 # n-element zero vector
zeros(m, n)              # m×n zero matrix
ones(n)                  # n-element ones vector
rand(m, n)               # m×n random matrix (uniform)
randn(m, n)              # m×n random matrix (normal)
LinRange(a, b, n)        # n evenly spaced points from a to b
range(a, b, length=n)    # same
collect(1:5)             # [1, 2, 3, 4, 5]
eachindex(v)             # iterator over valid indices
enumerate(v)             # (index, value) iterator
zip(a, b)                # paired iterator

# Array operations
v[1]                     # 1-indexed! (unlike Python's 0-indexed)
v[end]                   # last element
v[2:5]                   # slice (copy)
v[2:end]                 # to end
view(v, 2:5)             # non-copying view (fast)
push!(v, x)              # append in place (! = mutating)
pop!(v)                  # remove and return last
append!(v1, v2)          # extend v1 with v2
vcat(a, b)               # vertical concatenation
hcat(a, b)               # horizontal concatenation

# Broadcasting
f.(v)                    # apply f element-wise
a .+ b                   # element-wise add
a .* b                   # element-wise multiply
a .== b                  # element-wise comparison
@. expr                  # broadcast entire expression

# String operations
"$x and $y"              # interpolation
string(x)                # convert to String
parse(Int, "42")          # parse String to type
split("a,b,c", ",")       # ["a", "b", "c"]
join(["a","b","c"], "-")  # "a-b-c"
occursin("ll", "hello")  # true
replace("hello", "l"=>"r")  # "herro"

Conclusion

Julia delivers on its core promise: you can write code that reads like Python, reasons like mathematics, and runs like C. The two-language problem is genuinely solved for numerical work. DifferentialEquations.jl is better than anything in Python’s SciPy ecosystem. Multiple dispatch enables a composability that single-dispatch OOP languages struggle to match. The type system and JIT compiler work together to make generic, expressive code fast without manual optimization.

The costs are real too. Startup latency matters for certain workflows and is improving but not gone. The ecosystem is a fraction of Python’s size. The hiring pool is small. The learning curve around multiple dispatch and type stability is steeper than it looks from the outside.

The right answer for most engineers in 2026 is: know Python, add Julia where it fits. Julia fits when performance genuinely matters, when you’re solving differential equations or doing scientific computing, when you need parallelism without the GIL’s constraints, or when you want a language where your research code and your fast code are the same code.

The two-language problem is real. Julia is the most credible answer to it that exists.

Comments