Emacs project.el: Project-Aware Navigation With Zero Dependencies
Emacs's built-in project.el gives you project-aware file finding, project-wide grep, and buffer switching with zero external packages. Here's how root detection works, a practical configuration, and the limits to know.
03 Dec 2025, 01:50 UTC

If you reach for Projectile just to jump between files in a repository and grep across them, you may not need it. Emacs ships with project.el, a built-in library (bundled since Emacs 25, substantially expanded in Emacs 27 and 28) that provides project-aware file finding, project-wide search, buffer switching, and shell commands — with no external packages and, for most version-controlled repositories, no configuration at all.
How project.el decides what a project is
The core mechanism is root detection. When you invoke a project command, project.el walks up the directory tree from the current buffer's file, looking for a marker. By default the marker is a version-control directory such as .git, so any file inside a Git repository is automatically part of a project rooted at the repository top level. The first directory containing the marker wins, which means nested repositories behave the way most people expect: the innermost repo is the project.
Because detection is automatic, the practical workflow is: open any file in a repo, then use the C-x p prefix. The most useful bindings in Emacs 28 and later are:
C-x p f(project-find-file) — open any file in the project by name, with completion.C-x p g(project-find-regexp) — search for a regular expression across the project.C-x p b(project-switch-to-buffer) — switch between buffers that belong to the current project.C-x p p(project-switch-project) — switch to a previously seen project and run a command there.C-x p !(project-shell-command) — run a shell command with the project root as the working directory.
Verify these exist in your build before relying on them: run M-x emacs-version, then C-h f project-find-file. The C-x p keymap and parts of the command set are weaker or absent in older releases.
A worked configuration
Two adjustments cover most real-world friction. First, C-x p p normally prompts for a command after picking a project; if you almost always want to open a file, skip the prompt. Second, directories that are not under version control — a scratch prototype, a vendored tarball — can be recognized by teaching project.el extra root markers. In your init file:
;; After picking a project with C-x p p, go straight to file finding.
(setq project-switch-commands 'project-find-file)
;; Treat these files as project roots even without a VCS directory.
;; Emacs 29+; on older versions this variable may not exist.
(with-eval-after-load 'project
(when (boundp 'project-vc-extra-root-markers)
(setq project-vc-extra-root-markers '("package.json" "pom.xml"))))
The when (boundp ...) guard matters: project-vc-extra-root-markers was added in Emacs 29, and setting an unbound variable on older versions silently does nothing useful. To test the marker behavior, create a directory containing only a package.json file and a few source files (no .git), open one of the files, and run C-x p f. Completion should offer the other files in that directory, proving the marker-based root was detected.
For layouts the built-in logic cannot express — for example, treating each package inside a monorepo as its own project — project.el is extensible through the project-find-functions hook. A function added there receives a directory and returns a project instance (or nil to defer to the next function). This is how third-party backends plug in, and it is the supported extension point if you need custom project types.
Project-wide search and replace
C-x p g delegates to grep and presents results in an xref buffer. From there, M-g n and M-g p (or n/p inside the results buffer) step through matches in context. Because results are xref matches, you also get project-wide search-and-replace for free: run M-x xref-query-replace-in-results from the results buffer, supply the pattern and replacement, and confirm each substitution.
Search performance depends on the external grep tool. On large trees with the default grep, searches can be slow; installing ripgrep (rg) and configuring xref to use it (for example via xref-search-program where supported) is the usual fix. Check C-h v xref-search-program to see what your version supports.
Limits and common mistakes
- Assuming old-version parity. Tutorials often describe the Emacs 28/29 command set. If
C-x p fdoes nothing on your machine, check the version first rather than debugging your init file. - Expecting ignored files to appear. Whether files excluded by the VCS (build output,
.gitignored paths) show up inproject-find-filecompletion depends on the backend and version. If a generated or untracked file is missing from completion, this is usually why — open it by path instead. - Setting marker variables that don't exist yet. As shown above, guard version-dependent options with
boundporwhen (fboundp ...). - Blaming project.el for slow search. The bottleneck is the underlying grep tool, not the library; swap in ripgrep before concluding the feature is unusable on big trees.
The verification loop is short: open a file in a Git repository, run C-x p f, and confirm completion lists that repository's files. If it does, root detection, the keymap, and the completion pipeline are all working, and everything else — search, buffer switching, shell commands — builds on the same foundation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.