Emacs Package Management: Install, Load, and Everything In Between

2024/03/15

BUILDemacs🍞

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:

When you “use” a package, two separate things happen. This distinction is the single most important concept in Emacs package management:

  1. Installing – downloading the files to your system
  2. 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:

  1. Install Magit if it’s missing (:ensure t)
  2. Don’t load it until magit-status is called (:commands)
  3. Bind C-x g to magit-status
  4. 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:

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:

Do NOT version-control these:

The mantra:

Install with package.el or straight.el. Load with require or use-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