Emacs doesn’t have “apps.” It has packages. And understanding how packages work in Emacs is understanding how Emacs itself works – because Emacs is, at its core, a Lisp interpreter that happens to edit text. Packages are just more Lisp.
But the package ecosystem is confusing. There are at least three package managers, two loading strategies, a handful of package registries, and multiple framework-level abstractions (Doom, Spacemacs) that wrap the whole thing in their own conventions. If you search “how to install an Emacs package,” you’ll find five different answers, and all of them are correct for different setups.
This post covers the whole pipeline – from what a “package” even is, through installation and loading, to how modern frameworks like Doom Emacs handle it all for you. By the end, you should be able to look at any Emacs config and understand what’s happening.
What is a “package” in Emacs?
A package is one or more .el (Emacs Lisp) files that add functionality. That’s it. A color theme is a package. A Git client (Magit) is a package. A package that makes Emacs behave like Vim (evil-mode) is a package.
Every package, at minimum, has:
- A main
.elfile with a header containing metadata (name, version, author, dependencies) - One or more functions or variables it defines
When you “use” a package, two separate things happen. This distinction is the single most important concept in Emacs package management:
- Installing – downloading the files to your system
- Loading – making the functions available in your running Emacs
These are not the same step. You can install a package and never load it. You can have a package on disk and it does nothing until something triggers it to load. Understanding this split is the key to understanding everything else.
Installing: getting files onto disk
Installation means: the package’s .el files exist somewhere on your machine, and Emacs knows where to find them. The directory where packages live gets added to Emacs’ load-path – a list of directories Emacs searches when you ask it to load something.
There are three main ways to install packages:
package.el (built-in)
Since Emacs 24 (2012), Emacs ships with package.el. It’s the built-in package manager. It downloads packages from registries – ELPA (the official GNU registry), MELPA (the community registry with ~5,500 packages), and a few others – and drops them into ~/.emacs.d/elpa/.
;; Tell package.el where to find packages
(setq package-archives
'(("melpa" . "https://melpa.org/packages/")
("gnu" . "https://elpa.gnu.org/packages/")))
;; Refresh the package list and install something
(package-refresh-contents)
(package-install 'magit)
Or interactively: M-x package-install RET magit RET. Done.
Strengths: Built-in, zero setup, fine for simple configs.
Weaknesses: No reproducibility. package.el installs whatever the latest version is right now. Rebuild your config tomorrow and you might get different versions. There’s no lockfile, no pinning mechanism, no way to say “give me exactly this commit.” For a personal config you maintain for years, this becomes a problem.
straight.el (Git-based, reproducible)
straight.el takes a fundamentally different approach. Instead of downloading release tarballs from a registry, it clones the actual Git repositories. Every package is a Git repo on your disk, pinned to a specific commit.
;; Bootstrap straight.el (goes in early-init.el or init.el)
(defvar bootstrap-version)
(let ((bootstrap-file
(expand-file-name "straight/repos/straight.el/bootstrap.el" user-emacs-directory)))
(unless (file-exists-p bootstrap-file)
(with-current-buffer
(url-retrieve-synchronously
"https://raw.githubusercontent.com/radian-software/straight.el/develop/install.el")
(goto-char (point-max))
(eval-print-last-sexp)))
(load bootstrap-file nil 'nomessage))
;; Now install a package
(straight-use-package 'magit)
straight.el generates a lockfile (straight/versions/default.el) that records the exact commit hash of every package. Check that file into version control and you get fully reproducible builds – same packages, same versions, on any machine.
Strengths: Reproducible builds, Git-native (fork packages, pin commits, use local repos), lockfile support.
Weaknesses: More complex setup, slower initial clone (it’s cloning Git repos), requires understanding Git concepts.
Manual installation
You can always just drop .el files into a directory and add that directory to your load-path:
(add-to-list 'load-path "~/my-packages/some-package/")
This is how Emacs worked before package managers existed. You still see it for packages that aren’t on any registry, or for local development.
Loading: making things available
Installation gets files onto disk. Loading gets them into your running Emacs session. This is where startup time lives or dies.
Eager loading (require)
The simplest loading mechanism. require reads the package immediately:
(require 'magit)
When Emacs hits this line at startup, it searches load-path, finds magit.el, reads the entire file, and executes everything in it. All of Magit’s functions, variables, and keybindings are now available. The cost: Magit and all its dependencies are loaded whether or not you open a single Git repo in this session.
Multiply this by 30 packages and your startup time is measured in seconds. Five seconds. Eight seconds. Maybe more.
Lazy loading (autoload, deferred)
The alternative: don’t load the package until you actually need it.
The mechanism behind this is autoload. An autoload is a lightweight placeholder – it tells Emacs “this function exists, and when someone calls it, load the package first, then run the function.”
;; This doesn't load magit.el -- it just registers a placeholder
(autoload 'magit-status "magit" nil t)
Now magit-status is a known function, but Magit itself isn’t loaded. The first time you call M-x magit-status, Emacs loads the full package, replaces the placeholder with the real function, and runs it. Every subsequent call goes straight to the real function. You paid the loading cost exactly once, and only when you needed it.
There are several ways to trigger lazy loading:
| Trigger | What it means | Example |
|---|---|---|
| Command | Load when this function is called | :commands (magit-status magit-log) |
| Mode | Load when opening a file type | :mode ("\\.py\\'" . python-mode) |
| Hook | Load when entering a mode | :hook (prog-mode . rainbow-delimiters-mode) |
| Defer | Load after startup, in idle time | :defer t |
The practical effect: your Emacs starts in under a second, and packages load invisibly as you use them. This is what every modern Emacs config should be doing.
use-package: the config organizer
use-package is the glue. It’s not a package manager – it’s a macro that organizes your package configuration into clean, declarative blocks. It handles both installation (by talking to package.el or straight.el) and loading (by generating the right autoload and require calls).
Since Emacs 29, use-package is built-in.
Here’s what a typical block looks like:
(use-package magit
:ensure t ; install via package.el if not present
:commands magit-status ; lazy load -- only when calling magit-status
:bind ("C-x g" . magit-status)
:config
(setq magit-display-buffer-function
#'magit-display-buffer-same-window-except-diff-v1))
This single block says:
- Install Magit if it’s missing (
:ensure t) - Don’t load it until
magit-statusis called (:commands) - Bind
C-x gtomagit-status - After loading, configure the display behavior (
:config)
Without use-package, you’d need separate package-install calls, autoload declarations, with-eval-after-load blocks, and keybinding statements scattered across your config. use-package puts it all in one place.
The loading keywords
This is where people get confused. use-package’s default behavior is eager – if you write a bare block with no deferring keywords, the package loads immediately at startup.
;; This loads evil IMMEDIATELY at startup
(use-package evil
:config
(evil-mode 1))
;; This loads evil LAZILY -- only when you call evil-mode
(use-package evil
:commands evil-mode
:config
(evil-mode 1))
The deferring keywords are: :defer, :commands, :hook, :mode, :bind, :after. Any of these present means the package won’t load until triggered. If none are present, the package loads at startup. This is the #1 source of accidental startup bloat.
use-package with straight.el
Instead of :ensure t (which uses package.el), you can use :straight t:
(use-package magit
:straight t
:commands magit-status)
Or set straight-use-package-by-default to t and skip the property entirely. This gives you use-package’s config organization with straight.el’s reproducibility.
The three approaches, compared
| package.el | straight.el | use-package | |
|---|---|---|---|
| Role | Package manager | Package manager | Config organizer |
| Installs from | ELPA/MELPA tarballs | Git repos (any host) | Delegates to package.el or straight.el |
| Reproducible | No (no lockfile) | Yes (commit pinning + lockfile) | Depends on backend |
| Lazy loading | Manual (autoload) | Manual (autoload) | Built-in (:defer, :commands, etc.) |
| Built-in | Since Emacs 24 | No (bootstrap required) | Since Emacs 29 |
| Config format | Imperative (scattered calls) | Imperative | Declarative (per-package blocks) |
| Best for | Quick installs, simple configs | Reproducible setups, package development | Everyone (wrap around either manager) |
The practical recommendation for 2024: use use-package with straight.el as the backend. You get clean config organization, lazy loading, and reproducible builds. For simpler needs, use-package with :ensure t (package.el) is perfectly fine.
What Doom Emacs does differently
Doom Emacs is an opinionated framework that manages most of this for you. Understanding Doom’s approach is useful even if you don’t use it, because it shows how the pieces fit together at scale.
The three config files
Doom splits your config across three files in your DOOMDIR (usually ~/.doom.d/ or ~/.config/doom/):
| File | Purpose | When it runs |
|---|---|---|
packages.el |
Declare what to install | Read by doom sync |
config.el |
Configure packages | After everything loads |
init.el |
Enable Doom modules | Very early in startup |
The separation is strict. You declare packages in packages.el, you configure them in config.el. Mixing them up is a common mistake that leads to confusing errors.
package! – Doom’s install declaration
Instead of use-package’s :ensure t or calling straight-use-package, Doom uses its own package! macro:
;; In packages.el
(package! magit) ; install from MELPA
(package! my-fork
:recipe (:host github
:repo "me/my-fork")) ; install from GitHub
(package! evil :pin "e00626d9fd") ; pin to specific commit
(package! irony :disable t) ; disable a package
Under the hood, Doom uses straight.el for actual installation. But package! adds Doom-specific features: recipe overrides, pinning, disabling packages from other modules.
use-package! – Doom’s config wrapper
In config.el, Doom provides use-package! – a thin wrapper around use-package with Doom-specific defaults:
;; In config.el
(use-package! hl-todo
:hook (prog-mode . hl-todo-mode)
:config
(setq hl-todo-highlight-punctuation ":"))
The critical rule: do NOT use :ensure in Doom. Doom manages installation through packages.el. Adding :ensure t in a use-package! block will try to install via package.el, which Doom doesn’t use. This is the most common migration mistake.
doom sync – the glue command
After changing packages.el or init.el, you run:
doom sync
This is the command that actually installs packages, removes orphans, regenerates autoloads, and rebuilds byte-compiled files. Nothing takes effect until you run it. Think of it as npm install for your Emacs config.
Other useful commands:
| Command | What it does |
|---|---|
doom sync |
Install/remove packages, regenerate autoloads |
doom sync --rebuild |
Same + recompile all byte-code |
doom upgrade |
Update Doom + all packages |
doom doctor |
Diagnose common issues |
doom build |
Recompile packages (useful after Emacs version change) |
doom purge |
Clean up orphaned package repos |
The module system
Doom’s real power is its module system. Instead of configuring individual packages, you enable modules that bundle related packages and configuration:
;; In init.el -- the doom! block
(doom! :completion
company ; completion framework
:lang
python ; brings in python-mode, lsp, etc.
(org +hugo +jupyter) ; org-mode with optional features
:tools
magit) ; Git integration
Each module is a directory containing its own packages.el, config.el, and autoloads. Enable a module and you get a curated, pre-configured set of packages. Doom ships with ~160 modules.
What Doom’s abstraction buys you
The whole pipeline, in Doom:
init.el (doom! block) → which modules to load
↓
packages.el (package!) → which packages each module needs
↓
doom sync → straight.el clones/updates them
↓
config.el (use-package!) → lazy loading + configuration
↓
autoloads → generated stubs for lazy loading
You declare at a high level. Doom handles the plumbing. The tradeoff: when something goes wrong, you need to understand the full stack to debug it. But for day-to-day use, the abstraction holds up well.
Lazy loading: why it matters
A concrete example. Say your config uses 80 packages. Without lazy loading, Emacs loads all 80 at startup:
- Each package’s
.elfile is read and evaluated - Dependencies cascade – loading
lsp-modepulls indash,f,s,ht,spinner… - Startup takes 5-10 seconds
With lazy loading, Emacs registers autoloads for all 80 packages but only loads the 5-10 you actually use in the first few minutes. The rest load on demand, invisibly.
The difference: sub-second startup vs. multi-second startup. For an editor you might open and close multiple times a day, this matters.
Doom takes this further with byte-compilation. doom sync byte-compiles packages into .elc files – a pre-processed format that loads faster than raw .el. The combination of lazy loading + byte-compilation is why Doom can ship 160+ modules and still start in under a second.
Best practices for 2024+
Version-control these:
init.el(or~/.doom.d/init.elfor Doom)config.elpackages.elearly-init.el(if you use one)
Do NOT version-control these:
elpa/(package.el’s install directory – reproducible from config).local/straight/(straight.el’s install directory – reproducible from lockfile).local/(Doom’s entire package directory)
The mantra:
Install with
package.elorstraight.el. Load withrequireoruse-package. Prefer lazy loading for speed and clarity.
If you’re starting fresh: Use Doom or a similar framework. The package management problem is solved. Focus on learning Emacs, not on configuring package infrastructure.
If you’re maintaining a vanilla config: use-package + straight.el is the proven combination. Clean config organization, reproducible builds, lazy loading by default.
If you’re migrating to Doom from vanilla: Remove all :ensure properties from use-package blocks. Move package declarations to packages.el. Replace use-package with use-package!. Run doom sync. That’s the whole migration.
Resources
- MELPA – the community package registry (~5,500 packages)
- use-package README – the canonical reference for configuration syntax
- straight.el README – deep dive on reproducible package management
- Doom Emacs documentation – module system, package!, config patterns
- Built-in Emacs manual: Packages – official docs for package.el