A package is a directory of .odin files, each starting with the same package <name> line. Every file in that folder shares one flat scope — a top-level name in one file is just there for the next file, no import between them. Click a name to follow where it's used.
$ odin run . # build the FOLDER as a package
one folder, one package, one scope
hello, packages
@(private="file") (hidden from sibling files too) or @(private) (hidden from other packages). Visibility is something you add, not the default.
Sharing scope ends at the folder edge. To reach another package you import it — that binds a local name you call through (fmt.println). The string after a colon prefix says where to look; an optional name before the path renames it locally.
A name before the path is a local alias. Inside this file the package answers only to that name. Toggle it:
import strs "core:strings", the bare name strings is undeclared in that file. Reaching for it is a compile error: Undeclared name: strings (claim bare-name-undeclared). Aliasing is per-file; another file in the same package can bind a different name or use the default.
Level 2 sold you the import. Level 3 is the rulebook the compiler enforces around it — and each rule shows up as a refusal at build time, never a runtime surprise. Click one to see the exact error.
Four ways the package system can bite, four real compiler messages. Every string below is what the compiler actually printed.
The through-line: the directory boundary is load-bearing, and so is the package name on every file in it. Disagree on the name, point at a folder that isn't there, reach past @(private), or bind two packages to the same identifier — the compiler stops the build and tells you which one. None of these can survive to run.
@(private) is a decision the compiler makes once, during name resolution, about whether a name is visible outside its package. There's no check, no flag, no overhead at run time — the symbol is simply absent from what other packages can see, exactly like the four refusals above happen before a program exists.
All of it converges on one command. odin run . builds the current folder as a package — every .odin file in it, plus every package those files import. The single-file shortcut (-file) can't do this; a relative import needs the whole folder in view.
Here is the full lesson program — a package main that aliases core:strings, imports the sibling mymath folder (itself two files sharing scope), and calls across all of it. One odin run ., every name resolved:
$ odin run . lesson 01b - packages and imports contains 'hello' in 'hello world': true length of [3, 4] = 5.0000 circumference of r=1 circle = 6.2832 mymath version: 0.1.0 shouted: HELLOPE PI from constants.odin, used by vectors.odin: 3.14159265358979
The emergent payoff: that last line is the whole idea in one print. PI is defined in mymath/constants.odin; it's used by mymath/vectors.odin with no import between them — because they're one package. main reaches the result through a single import "./mymath" and the mymath. qualifier. Folders gave you free intra-package sharing; imports gave you deliberate, named cross-package access. Two keywords, and the scope graph of an entire program falls out of the directory tree.
And then they're gone: packages are a compile-time concept only. There's no runtime package object, no reflection over packages, and an import runs no code — it just tells the compiler to also consider those names while resolving this file. By the time the program runs, every name is an address and the boundaries have been erased.
L1 a folder of files sharing one flat scope, no imports between them → L2 an import binds a name from another package, an alias renaming it locally → L3 the compiler enforces the boundary with four exact refusals → L4 odin run . builds the folder, wires every import, then erases the packages at runtime.