Modules, Packages & Environments#

Julia’s package manager is, in my honest opinion, the best of any language I’ve used. That’s a strong claim about a boring topic, so let me justify it early: Julia environments are reproducible by default and cost nothing to create. No virtualenv to remember to activate, no dependency resolution that quietly installs a different version than your colleague got. Two commands and you have an exactly reproducible project.

Modules: organizing code within a project#

A module is a namespace. It groups related definitions and controls what’s visible from outside.

module Ledger

export post_entry, balance_of

struct Entry
    account::String
    amount::Float64
end

post_entry(account, amount) = Entry(account, amount)

balance_of(entries, account) =
    sum(e.amount for e in entries if e.account == account; init = 0.0)

# Not exported — internal helper
normalize_account(s) = uppercase(strip(s))

end # module

Using it:

using .Ledger              # the dot means "a module defined right here"

e = post_entry("GL-4001", 1200.0)     # exported, available directly
Ledger.normalize_account(" gl-4001 ") # not exported, but still reachable

Two things to note.

export is a suggestion, not a wall. Anything in the module can be reached with Ledger.name. Julia doesn’t have private members. The convention is that unexported names are internal and may change without warning — an honour system, and it works fine in practice.

Note

The public keyword Julia 1.11 added a public keyword to fill a real gap. Sometimes you want to say “this name is part of my stable API” without exporting it into everyone’s namespace. public foo marks foo as officially supported API that users should still access as MyModule.foo.

module Ledger
export post_entry           # comes into scope with `using`
public normalize_account    # stable API, but call it as Ledger.normalize_account
...
end

This is available on both the current stable line and the 1.10 LTS via version-guarded code, though you’ll mostly see it in newer packages.

using vs import#

using Statistics            # brings exported names into scope: mean, std, ...
using Statistics: mean      # brings in exactly `mean`, nothing else
import Statistics           # brings nothing into scope; use Statistics.mean
import Statistics: mean     # brings in `mean`, AND lets you add methods to it

The distinction that actually matters day to day: if you want to add a method to a function from another module, you must import it (or write the fully qualified name Base.:+). using gives you read access; import gives you extension rights. That’s a deliberate safety rail — you can’t accidentally hijack someone’s function by defining a similarly-named one.

Environments: the part you should care about#

Every Julia project gets its own environment, defined by two files:

  • Project.toml — your direct dependencies, and the version ranges you’ll accept. You write this (via the package manager). It’s short and human-readable.

  • Manifest.toml — the exact version of every package in the entire dependency tree, including transitive ones. Generated for you. Never edit it by hand.

Commit both to git and anyone who clones your repo can reproduce your exact stack with one command. This is the whole ballgame, and it’s why I lead with it.

Creating and using an environment#

In the REPL, press ] to enter package mode. The prompt changes to pkg>.

(@v1.12) pkg> activate .          # create/use an environment in this directory
(myproject) pkg> add DataFrames CSV Statistics
(myproject) pkg> status

Or from the shell, which is the habit I’d recommend:

julia --project=.

That starts Julia with the current directory’s environment already active. Put it in an alias, or let VS Code do it for you (the Julia extension detects Project.toml automatically).

Warning

The most common beginner mistake If you just run julia and start adding packages, they go into your global environment (@v1.12). Everything works, right up until you have two projects that need different versions of the same package, or you try to give your code to someone else and discover you have no idea what it depends on.

Get in the habit now: one directory, one project, --project=.. It costs you nothing and it’s the difference between “works on my machine” and “works.”

Package mode commands you’ll actually use#

pkg> add DataFrames              # add a dependency
pkg> add DataFrames@1.6          # pin a version
pkg> rm DataFrames               # remove
pkg> update                      # update within the ranges in Project.toml
pkg> status                      # what's installed
pkg> instantiate                 # install exactly what Manifest.toml says
pkg> test                        # run the package's tests
pkg> gc                          # reclaim disk from old versions

instantiate is the one to remember. Clone a repo, run julia --project=. then ] instantiate, and you have the author’s exact environment. That’s reproducibility that actually reproduces.

Reproducing someone’s work#

The full ritual, start to finish:

git clone https://github.com/someone/their-analysis
cd their-analysis
julia --project=. -e 'using Pkg; Pkg.instantiate()'
julia --project=. scripts/run_analysis.jl

Three commands. No conda, no Docker required, no version drift.

Packages worth knowing#

The ecosystem is smaller than Python’s but strong where it’s strong. A starting map for the kind of work this series is heading toward:

Data

  • DataFrames.jl — tabular data, the pandas equivalent

  • CSV.jl — fast CSV reading and writing

  • Arrow.jl, Parquet2.jl — columnar formats

  • Query.jl / DataFramesMeta.jl — query DSLs

Numerics and stats

  • Statistics, LinearAlgebra, Random — in the standard library, no install needed

  • Distributions.jl — probability distributions

  • StatsBase.jl, GLM.jl — statistics and regression

Plotting

  • Plots.jl — the general-purpose front end with swappable backends

  • Makie.jl — high-performance, interactive, GPU-capable

  • AlgebraOfGraphics.jl — grammar-of-graphics on top of Makie

Machine learning

  • Flux.jl — neural networks, written in pure Julia

  • MLJ.jl — a unified interface across many ML models

  • SciML ecosystem (DifferentialEquations.jl and friends) — the crown jewel, honestly

Development

  • Revise.jl — auto-reloads your code as you edit. Install this first, before anything else.

  • BenchmarkTools.jl — reliable microbenchmarking

  • Test — in the standard library

  • JET.jl — static analysis that finds type instabilities and errors

Note

Set up Revise once, benefit forever Create ~/.julia/config/startup.jl with:

try
    using Revise
catch e
    @warn "Revise not loaded"
end

Now every Julia session you start automatically picks up edits to your package code without a restart. Combined with 1.12’s redefinable structs, this makes the development loop feel genuinely interactive.

Creating your own package#

When your script grows into something you want to reuse or share:

pkg> generate MyAnalysis

That scaffolds:

MyAnalysis/
├── Project.toml
└── src/
    └── MyAnalysis.jl

Add a test/runtests.jl and you have the standard layout the whole ecosystem uses:

# test/runtests.jl
using MyAnalysis
using Test

@testset "MyAnalysis" begin
    @test net_pay(1000, 0.1)  900
    @test_throws ArgumentError net_pay(-1, 0.1)
end

Run it with ] test. That’s the entire testing setup — Test ships with Julia, there’s nothing to install and no configuration file.

Note

Reproducible test failures As of Julia 1.12, when a @testset fails, Julia prints the random number generator seed it was using. Paste that seed back into your test set and you reproduce the exact failing run. If you’ve ever chased a test that fails one time in fifty, you’ll appreciate this more than the feature list suggests.

Try it yourself#

  1. Create a new directory, start Julia with --project=., and add Statistics and BenchmarkTools. Look at the Project.toml and Manifest.toml that appear.

  2. Write a small module with one exported function and one unexported helper. Confirm you can call the helper with the qualified name.

  3. Run ] status and then ] rm BenchmarkTools, and watch the Project.toml change.

  4. Delete Manifest.toml, run ] instantiate, and confirm you get the same environment back.

You can now organize code and manage dependencies like a professional. Next we’ll look at the feature that lets Julia programs write Julia programs: metaprogramming.