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 equivalentCSV.jl— fast CSV reading and writingArrow.jl,Parquet2.jl— columnar formatsQuery.jl/DataFramesMeta.jl— query DSLs
Numerics and stats
Statistics,LinearAlgebra,Random— in the standard library, no install neededDistributions.jl— probability distributionsStatsBase.jl,GLM.jl— statistics and regression
Plotting
Plots.jl— the general-purpose front end with swappable backendsMakie.jl— high-performance, interactive, GPU-capableAlgebraOfGraphics.jl— grammar-of-graphics on top of Makie
Machine learning
Flux.jl— neural networks, written in pure JuliaMLJ.jl— a unified interface across many ML modelsSciMLecosystem (DifferentialEquations.jland friends) — the crown jewel, honestly
Development
Revise.jl— auto-reloads your code as you edit. Install this first, before anything else.BenchmarkTools.jl— reliable microbenchmarkingTest— in the standard libraryJET.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#
Create a new directory, start Julia with
--project=., and addStatisticsandBenchmarkTools. Look at theProject.tomlandManifest.tomlthat appear.Write a small module with one exported function and one unexported helper. Confirm you can call the helper with the qualified name.
Run
] statusand then] rm BenchmarkTools, and watch theProject.tomlchange.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.