Julia Module 8 – Modules, packages and reproducible environments
Modules, using, import, export and public
Organise Julia code into modules and files, choose between using and import, mark an API with export and public, and read settings in __init__.
What you will learn
- Organise code into modules, submodules and files, and reach names by qualified and relative paths
- Choose between using and import, and add a method to another module's function the right way
- Declare a module's API with export and public, and check it with names and Base.ispublic
- Read settings at load time in __init__, not in top-level code that runs at precompile time
Before you start
What you’ll learn · Before you start
What you will learn
- Organise code into modules, submodules and files, and reach names by qualified and relative paths
- Choose between using and import, and add a method to another module's function the right way
- Declare a module's API with export and public, and check it with names and Base.ispublic
- Read settings at load time in __init__, not in top-level code that runs at precompile time
Before you start
On this page
Two files of the same analysis both define normalise(x), one for a rain series and one for station names, and the
script that includes both keeps whichever came last. A module stops that: each has its own global scope, so
Rain.normalise and Names.normalise are different functions, and a module says which of its names others should
use.
This lesson builds a module and its submodules, chooses between using and import, marks an API with export and
public, and reads a setting in __init__, the one place that runs every time a package loads. The sessions run
Julia 1.13.1, and two of them run again on the 1.10 LTS, where the same files behave differently.
A module is a namespace, and a qualified name reaches inside it
module Geometry ... end creates a module: A named namespace of Julia code with its own global scope, written module Name ... end. Names inside it are reached from outside as Name.x, or brought in with using and import.: a namespace with its own global scope. Inside,
export area offers area to anyone who writes using, public perimeter marks perimeter as part of the API
without offering it, and check is an internal helper:
# A module is a namespace: what export, public and an internal helper each make visible.
module Geometry
export area
public perimeter
area(r::Real) = (check(r); π * r^2)
perimeter(r::Real) = (check(r); 2π * r)
check(r) = r >= 0 || throw(DomainError(r, "a radius cannot be negative"))
end
using .Geometry
println("area(3.0) = ", area(3.0))
println("Geometry.perimeter(3.0) = ", Geometry.perimeter(3.0))
println("Geometry.check(3.0) = ", Geometry.check(3.0), " (internal, yet a qualified name reaches it)")
for name in (:area, :perimeter, :check)
println(rpad(name, 10), "exported: ", rpad(Base.isexported(Geometry, name), 6),
"public: ", rpad(Base.ispublic(Geometry, name), 6), "usable without Geometry.: ", isdefined(Main, name))
end
println("names(Geometry) = ", names(Geometry))
try
perimeter(3.0)
catch err
println(first(split(sprint(showerror, err), '\n')))
end from session import files, run
# One module with an exported name, a public one and an internal helper.
files("geometry.jl")
run("""
julia geometry.jl
""") Output
$ julia geometry.jl area(3.0) = 28.274333882308138 Geometry.perimeter(3.0) = 18.84955592153876 Geometry.check(3.0) = true (internal, yet a qualified name reaches it) area exported: true public: true usable without Geometry.: true perimeter exported: false public: true usable without Geometry.: false check exported: false public: false usable without Geometry.: false names(Geometry) = [:Geometry, :area, :perimeter] UndefVarError: `perimeter` not defined in `Main`
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 geometry_session.py
After using .Geometry, the dot meaning “a module defined here, not a package”, plain area(3.0) worked and gave
28.27, while plain perimeter(3.0) was an UndefVarError in Main. Yet Geometry.perimeter(3.0) and even
Geometry.check(3.0) worked: a qualified name: A name written with its module in front, such as Geometry.perimeter or Base.sqrt. It always reaches the name, whether the module exports it, marks it public or neither. always reaches a module’s names. The
manual is direct about it: Julia has no way to truly hide a module’s internals. export and public say what you
should use, not what you can reach. Step through what using changes:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The module. Geometry defines three names. area is exported, perimeter is marked public, and check is a helper with no mark at all.
- using .Geometry. The dot means a module defined in this file, not a package. using makes the module name and its exported names usable in Main: here Geometry and area.
- A plain call. area(3.0) needs no prefix because area is exported. It returned 28.27, the area of a circle of radius 3.
- Public, not exported. perimeter is part of the API, but public does not bring it into Main, so plain perimeter(3.0) threw UndefVarError:
perimeternot defined inMain. - Nothing is hidden. Geometry.check(3.0) returned true. A qualified name reaches even an internal helper: export and public say what to use, not what can be reached.
names(Geometry) listed the module itself, area and perimeter, the names that are public; check appears only
with names(Geometry; all = true).
Checkpoint
Check: Modules and qualified names
using brings exported names in, and import lets you add methods
using Geometry makes the exported names usable, and import Geometry only the module’s name. Both accept a list,
using Geometry: area or import Geometry: area, and the difference between those two decides how you add a method
to area. Three modules try, each with a shape of its own:
# Adding a method to a function that another module owns: two ways that work, and one that does not.
module Geometry
export area
area(r::Real) = π * r^2
end
module Squares # the function's module name in front: always extends it
using ..Geometry
struct Square; side::Float64; end
Geometry.area(s::Square) = s.side^2
end
module Discs # import brings the name in so that it can be extended
import ..Geometry: area
struct Disc; r::Float64; end
area(d::Disc) = area(d.r)
end
println("Geometry.area has ", length(methods(Geometry.area)), " methods: ",
Geometry.area(Squares.Square(3.0)), " and ", round(Geometry.area(Discs.Disc(1.0)), digits = 4))
try
@eval module Hexagons # a plain definition after `using`, once the name has been used
using ..Geometry
const UNIT = area(1.0)
struct Hexagon; side::Float64; end
area(h::Hexagon) = 3 * sqrt(3) / 2 * h.side^2
end
f = Hexagons.area
println("Hexagons: area is now ", parentmodule(f), ".area, a new function with ", length(methods(f)), " method")
println("Hexagons.area(1.0): ", try f(1.0) catch err; nameof(typeof(err)) end)
catch err
println("Hexagons: ", first(split(sprint(showerror, err), '\n')))
end from session import files, run, use
# Three modules add a method to Geometry.area, on Julia 1.13.1 and on the 1.10.12 LTS.
files("extend.jl")
run("""
julia --version
julia extend.jl
""")
use("1.10.12")
run("""
julia --version
julia extend.jl
""") Output
$ julia --version julia version 1.13.1 $ julia extend.jl Geometry.area has 3 methods: 9.0 and 3.1416 Hexagons: area is now Main.Hexagons.area, a new function with 1 method Hexagons.area(1.0): MethodError $ julia --version julia version 1.10.12 $ julia extend.jl Geometry.area has 3 methods: 9.0 and 3.1416 Hexagons: invalid method definition in Hexagons: function Geometry.area must be explicitly imported to be extended
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extend_session.py
Squares wrote Geometry.area(s::Square) = …, which extends the function wherever it lives. Discs wrote
import ..Geometry: area, after which a plain area(d::Disc) = … extends it too; Geometry.area ended with three
methods on both versions. Hexagons wrote a plain definition after using, and the versions part ways. On 1.10.12
the definition stopped with function Geometry.area must be explicitly imported to be extended. On 1.13.1 it made a
new function, Main.Hexagons.area, with one method, so inside Hexagons the name area no longer meant the
function it had just used, and area(1.0) was a MethodError. The manual’s section on the precedence of
definitions explains it: a name brought in by plain using is the weakest kind of binding, and a definition of your
own replaces it.
| You write | What it brings in | Can a plain area(…) = … add a method? |
|---|---|---|
using Geometry |
Geometry and every exported name |
No: on 1.12 and later it makes a new function |
using Geometry: area |
Only area |
No: an error, explicitly imported to be extended |
import Geometry |
Only Geometry |
Write Geometry.area(…) = … |
import Geometry: area |
Only area |
Yes |
Extending a function after a plain using
Wrong: using Geometry and then area(s::Square) = …, which on 1.12 and later quietly defines your own area and
hides the one you meant. Right: Geometry.area(s::Square) = …, which reads as what it is, or
import Geometry: area at the top of the file.
For code that others depend on, the manual recommends listing what you use, using Foo: Foo, f, rather than plain
using Foo: a new export in a later version of Foo then cannot collide with your own names.
Checkpoint
Check: using, import and adding methods
export puts a name in scope, and public only marks it as API
Both keywords declare public API: The names a module promises to keep working across versions: the names it exports and the names it marks public. Its other names are internal and may change in any release., the names a module promises to keep working across
versions; only export also hands the name to using. public is new in Julia 1.11, and the same three files show
what that means on the 1.10 LTS:
public_keyword.jl
# The public keyword: Julia 1.11 and later.
module Stations
export lookup
public describe
lookup(code) = code
describe(code) = "station $code"
end
println("public works: ", Base.ispublic(Stations, :describe)) public_compat.jl
# The form the manual gives for code that must also load on Julia 1.10 and earlier.
module Stations
export lookup
VERSION >= v"1.11.0-DEV.469" && eval(Meta.parse("public describe"))
lookup(code) = code
describe(code) = "station $code"
end
println("loaded on ", VERSION, "; describe is public: ",
isdefined(Base, :ispublic) ? Base.ispublic(Stations, :describe) : "(1.10 has no public)") public_and_export.jl
# One name declared both exported and public.
module Stations
export describe
public describe
describe(code) = "station $code"
end from session import files, run, use
# public on 1.13.1 and on the 1.10.12 LTS, and a name that is both exported and public.
files("public_keyword.jl", "public_compat.jl", "public_and_export.jl")
run("""
julia --version
julia public_keyword.jl
julia public_compat.jl
!julia public_and_export.jl
""")
use("1.10.12")
run("""
julia --version
!julia public_keyword.jl
julia public_compat.jl
""") Output
$ julia --version julia version 1.13.1 $ julia public_keyword.jl public works: true $ julia public_compat.jl loaded on 1.13.1; describe is public: true $ julia public_and_export.jl ERROR: LoadError: cannot declare Stations.describe public; it is already declared exported Stacktrace: [1] include(mod::Module, _path::String) @ Base ./Base.jl:309 [2] exec_options(opts::Base.JLOptions) @ Base ./client.jl:344 [3] _start() @ Base ./client.jl:577 in expression starting at ~/demo/public_and_export.jl:2 [exit status 1] $ julia --version julia version 1.10.12 $ julia public_keyword.jl ERROR: LoadError: ParseError: # Error @ ~/demo/public_keyword.jl:4:8 export lookup # ┌─────── public describe lookup(code) = code #─────────┘ ── Expected `end` Stacktrace: [1] top-level scope @ ~/demo/public_keyword.jl:4 in expression starting at ~/demo/public_keyword.jl:4 [exit status 1] $ julia public_compat.jl loaded on 1.10.12; describe is public: (1.10 has no public)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 api_versions_session.py
On 1.13.1, public describe worked and Base.ispublic(Stations, :describe) was true. Declaring one name both
export and public stopped the file: cannot declare Stations.describe public; it is already declared exported, an
error since 1.12, because an exported name is public already. On 1.10.12 the keyword itself was a ParseError, so a
package that still supports the LTS uses the guarded form from the manual,
VERSION >= v"1.11.0-DEV.469" && eval(Meta.parse("public describe")), which loaded on both. The keywords arrived in
this order:
- 1.0
export, using and import
export lists the names that using brings into the caller's scope. import M: f brings one name in so that methods can be added to it. A qualified name such as M.f reaches every name, marked or not.
- 1.11
The public keyword new keyword
public marks a name as API without bringing it into anyone's scope, and Base.ispublic tests it. On 1.10 the keyword is a parse error, so code for both uses the manual's VERSION-guarded form.
- 1.12
Public or exported, never both new error
Declaring one name both public and exported became an error. names gained usings = true, and the REPL warns when a name is reached through a module that neither defines it nor makes it public.
The timeline has 3 events, from 1.0 to 1.12.
The REPL of 1.12 and later also helps keep to the API. It warns when you reach a name through a module that neither defines it nor declares it public:
from session import repl
# The REPL of Julia 1.12 and later warns when a name is reached through a module that neither defines it nor
# declares it public.
repl([
"module Geometry; check(r) = r >= 0; end",
"Geometry.check(1.0)",
"Geometry.sqrt(16.0)",
"Base.sqrt(16.0)",
]) Output
$ julia --startup-file=no -q julia> module Geometry; check(r) = r >= 0; end Main.Geometry julia> Geometry.check(1.0) true julia> Geometry.sqrt(16.0) [ Warning: sqrt is defined in Base and is not public in Main.Geometry 4.0 julia> Base.sqrt(16.0) 4.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repl_warning_session.py
Geometry.check(1.0) passed quietly, since Geometry defines check. Geometry.sqrt(16.0) worked too, but with a
warning that sqrt belongs to Base: code that reaches names that way breaks if the module stops using them.
Key idea
Export the few names users call all the time, mark the rest of your API public, and leave helpers unmarked:
qualified names still reach everything, but only the public names are a promise.
Checkpoint
Check: export and public
A module can span files, and submodules find each other by relative paths
include("units.jl") runs a file as if its text stood at that point of the module, so one module can be split
across files. Modules nest too, and a submodule reaches its siblings with a relative path: .Units is a module inside
the current one, and each further dot goes up one level. Lab is made of three files:
lab/Lab.jl · lab/Lab.jl
# The top module: one file per part, each included into the module that needs it.
module Lab
export summary_line
include("units.jl") # defines the submodule Units
include("report.jl") # defines the submodule Report, which uses Units
using .Report: summary_line
end lab/units.jl · lab/units.jl
module Units
export mm_to_cm
mm_to_cm(x) = x / 10
const SYMBOL = "cm"
end lab/report.jl · lab/report.jl
module Report
using ..Units # a sibling module: up one level to Lab, then down to Units
export summary_line
summary_line(station, mm) = string(station, ": ", mm_to_cm(mm), " ", Units.SYMBOL)
end use_lab.jl
# Load a module made of three files, and reach its parts by relative and full paths.
include("lab/Lab.jl")
using .Lab
println(summary_line("Pune", 722.0))
println("Lab.Units.mm_to_cm(150.0) = ", Lab.Units.mm_to_cm(150.0))
println("fullname(Lab.Report) = ", fullname(Lab.Report), ", parentmodule(summary_line) = ", parentmodule(summary_line))
println("names(Lab) = ", names(Lab)) from session import files, run
# A module split into three files with include, and a script that uses it.
files("use_lab.jl", "lab/Lab.jl", "lab/units.jl", "lab/report.jl")
run("""
julia use_lab.jl
""") Output
$ julia use_lab.jl Pune: 72.2 cm Lab.Units.mm_to_cm(150.0) = 15.0 fullname(Lab.Report) = (:Main, :Lab, :Report), parentmodule(summary_line) = Main.Lab.Report names(Lab) = [:Lab, :summary_line]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lab_session.py
The diagram shows where each module lives and what each using reaches:
The modules of the lab folder, each with the file it lives in
Text description of the diagram
Each box is a module, drawn inside the module that contains it, with the file its code comes from.
- Main is the script use_lab.jl, which includes lab/Lab.jl and writes using .Lab.
- Lab is defined in lab/Lab.jl. It includes units.jl and report.jl, so both submodules live inside Lab.
- Units, from lab/units.jl, exports mm_to_cm.
- Report, from lab/report.jl, writes using ..Units: two dots go up one level to Lab and then down to its sibling Units. It exports summary_line, which Lab takes with using .Report: summary_line and exports again.
Report wrote using ..Units, up one level to Lab and down to Units, so mm_to_cm worked inside it and
summary_line("Pune", 722.0) printed 72.2 cm. fullname(Lab.Report) traced the path from Main, and
names(Lab) listed summary_line, which Lab took from Report and exported again. Files and modules are
independent: one module may span many files and one file may hold several modules, but a package keeps one top-level
module, in src/<Name>.jl.
Checkpoint
Check: Files, submodules and relative paths
The init function runs at every load, and top-level code only at precompile time
A package’s module is compiled once and saved in the depot: A folder where Julia keeps packages, registries, compiled code and logs. Pkg writes to the first entry of DEPOT_PATH, which is ~/.julia by default. More in “Install Julia with juliaup and manage versions”’s cache; later sessions load the
cache instead of running the source again, and any value top-level code computes is saved with it. A function named
__init__: A function named __init__ in a module. Julia calls it every time the module is loaded, after a precompiled module has been read from its cache, so it suits work that depends on the session. is the exception, run at every load. Settings reads the environment variable
RAIN_UNITS twice, at top level and in __init__:
module Settings
export units_at_precompile, units_at_load
# Top-level code runs while the package is precompiled; the value is saved in the cache with the code.
const UNITS_AT_PRECOMPILE = get(ENV, "RAIN_UNITS", "mm")
# __init__ runs every time the package is loaded, after the cache has been read.
const UNITS_AT_LOAD = Ref("")
function __init__()
UNITS_AT_LOAD[] = get(ENV, "RAIN_UNITS", "mm")
end
units_at_precompile() = UNITS_AT_PRECOMPILE
units_at_load() = UNITS_AT_LOAD[]
end from session import files, run
# A package that reads RAIN_UNITS twice: in top-level code and in __init__. The first run precompiles it.
files("Settings/Project.toml", "Settings/src/Settings.jl")
run("""
julia --project=Settings -e 'using Settings; println(units_at_precompile(), " ", units_at_load())'
RAIN_UNITS=cm julia --project=Settings -e 'using Settings; println(units_at_precompile(), " ", units_at_load())'
ls ~/.julia/compiled/v1.13
""") Output
$ julia --project=Settings -e 'using Settings; println(units_at_precompile(), " ", units_at_load())' mm mm $ RAIN_UNITS=cm julia --project=Settings -e 'using Settings; println(units_at_precompile(), " ", units_at_load())' mm cm $ ls ~/.julia/compiled/v1.13 Settings
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 init_session.py
The first run compiled the package, with RAIN_UNITS unset, and both readings said mm. The second run set
RAIN_UNITS=cm. __init__ read cm, because Julia calls a module’s __init__ every time the module is loaded. The
top-level constant still said mm: it was computed during precompilation and loaded from the cache, and changing an
environment variable does not make Julia precompile again. Follow both runs:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- No cache yet. The first using Settings finds no compiled copy, so Julia precompiles the package: it runs the module's top-level code once, in a separate process.
- Top-level code runs. const UNITS_AT_PRECOMPILE = get(ENV, "RAIN_UNITS", "mm") reads the variable now. It is unset, so the constant is "mm".
- Saved with the code. The compiled module goes into the cache, and the value of the constant goes with it.
- __init__ at load. Loading the module calls __init__, which reads RAIN_UNITS again: still unset, so "mm". The first run printed mm mm.
- A new setting. The second run sets RAIN_UNITS=cm. The source has not changed, so the cache is still valid and Julia loads it; the top-level code does not run again.
- Two different answers. __init__ runs at this load and reads "cm". The constant was computed in the first run and still says "mm", so the second run printed mm cm.
The modules chapter of the manual makes it a rule: state that belongs to the machine the code runs on, such as the
set-up of a C library and the pointers it returns, is computed in __init__, because a cache does not preserve
external state. Environment variables, open files and the current time are state of the same kind.
Checkpoint
Check: __init__ and precompile time
Practise: give a script an API
Turn a script into a module with a declared API
Take a script of your own with five or six functions and put them in a module. Export the two names users call
most, mark two more public, and leave the rest unmarked. Check on your machine that names(YourModule) lists
exactly the four API names, that Base.ispublic is false for the helpers, and that a file which adds a method to
one of your functions after a plain using fails a test you write for it.
The self-check covers namespaces, using and import, export and public, submodules and __init__. Its first
questions come from the part you have answered least well so far.
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why mark a name public instead of exporting it?
Both make a name part of the module’s public API, the names it promises to keep working across releases. export
also brings the name into the scope of everyone who writes using TheModule, so a common word such as normalise
can collide with other packages’ exports, and every new export is a new chance of a clash. public keeps the
promise without that effect: callers write TheModule.normalise. Export the few names users call all the time and
mark the rest of the API public. public needs Julia 1.11 or later, and since 1.12 declaring one name both ways is
an error.
Two design questions follow: an update that breaks a script with a name clash, and a setting that will not change until the cache is deleted. Say your first move for each, then open its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
An analysis script starts with using Smoothing, Stats. After an update of Stats, every call to smooth(x) fails with an UndefVarError whose hint says two modules export different bindings with this name. Nothing in your script changed. Explain what happened and how you would write imports that survive updates.
-
Mid to staff
A package reads the path of its data folder from an environment variable in a top-level const. Users report that changing the variable has no effect until they delete their compiled cache, and two machines that share a depot disagree about the folder. Explain why, and fix the package.
Key takeaways
- A module is a namespace with its own global scope; a qualified name such as
Geometry.checkreaches any of its names, andexportandpubliconly declare which ones to use. using MbringsMand its exported names,import M: flets a plain definition add methods tof, and on 1.12 and later a plain definition afterusingmakes a new function instead.public(1.11 and later) marks API without exporting it, declaring a name both ways is an error from 1.12, and the 1.10 LTS needs theVERSION-guarded form.includesplits a module across files, and submodules reach siblings with relative paths such as..Units.- Top-level code of a package runs when it is precompiled and its results are cached, while
__init__runs at every load, so settings from the environment are read in__init__.
Terms in this lesson
- __init__
- A function named __init__ in a module. Julia calls it every time the module is loaded, after a precompiled module has been read from its cache, so it suits work that depends on the session.
- module
- A named namespace of Julia code with its own global scope, written module Name ... end. Names inside it are reached from outside as Name.x, or brought in with using and import.
- public API
- The names a module promises to keep working across versions: the names it exports and the names it marks public. Its other names are internal and may change in any release.
- qualified name
- A name written with its module in front, such as Geometry.perimeter or Base.sqrt. It always reaches the name, whether the module exports it, marks it public or neither.
References
- Modules (The Julia Project)
- Essentials: names, Base.ispublic and Base.isexported (The Julia Project)
- Julia 1.11 release notes: the public keyword (The Julia Project)
- Julia 1.12 release notes: public and exported, names with usings, REPL warnings (The Julia Project)
- Code loading (The Julia Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress