Advanced Python Development Workflow in Emacs

42 minute read

Introduction

Why Emacs?

Emacs is not just an editor — it’s an extensible platform for crafting highly personalized development environments. With tools like company, yasnippet, lsp-mode and dap-mode, Emacs transforms into a feature-rich Python IDE capable of competing with PyCharm or VS Code.

This guide explores how to evolve Emacs into a robust Python IDE. Whether you’re writing simple scripts or managing large-scale projects, you’ll find a setup that aligns with your engineering needs and reflects the power of Emacs customization.

Goals and Scope

The purpose of this guide is to meticulously explore how Emacs can be transformed into a advanced Python IDE. This is not a universal manual intended for everyone — it is deeply tailored to my specific needs, preferences, and engineering rigor. Throughout this document, I aim to combine practical usability with transparent configuration, balancing power and simplicity.

This guide reflects my personal journey in creating an IDE that meets my own standards of excellence. Could Emacs be made “better”? Absolutely, because the concept of “better” is always contextual. Could someone else choose different tools than I did? Certainly. The tools I selected represent the choices I made based on my criteria and understanding, which I will detail throughout the guide.

At its core, this is a collection of my thoughts, experiments, and findings — a snapshot of my current understanding of how to make Emacs the ultimate Python development environment. While it may not suit everyone, I hope it inspires others to refine their workflows and explore the potential of Emacs as a highly customizable IDE.

A note on some conscious exclusions: I do not use use-package in my personal Emacs configuration and this guide. While it may be a popular choice in the Emacs community, I have not found it particularly valuable for my workflow. I manage my configuration manually, and this approach works perfectly for me. If you are a use-package user, you might need to adapt discussed configuration to fit your own setup — I leave this as an exercise for you.

Similarly, you will not find anything related to evil-mode or its keybindings in this guide. If I want to use Vim, I simply use Vim. I see no reason to mimic Vim within Emacs, as I prefer to embrace Emacs for what it is.

Overview of the Configuration Stack

Modern IDEs go beyond text editing — they’re ecosystems that support coding, debugging, and managing environments seamlessly. Transforming Emacs into an IDE tailored for Python requires a stack of modular tools, each addressing a specific aspect of the development workflow:

  • Code Intelligence: Autocompletion, navigation, and type checking enhance productivity and help catch errors early.
  • Linting and Static Analysis: Real-time feedback ensures code adheres to best practices and avoids common pitfalls.
  • Debugging: Integrated debugging tools streamline the process of finding and fixing issues.
  • Environment Management: Seamless management of Python environments and dependencies simplifies setup and reduces context switching.

By strategically combining these tools, we can craft a Python IDE that adapts to diverse project demands — from quick scripts to complex applications.

Foundations of Key Protocols

Before delving deeper into the tools and their configurations, we have to understand some foundational concepts and protocols that underpin the technologies used in this guide. This section provides a brief overview of two critical protocols: Language Server Protocol and Debug Adapter Protocol. These protocols form the backbone of modern IDEs, enabling modularity, flexibility, and seamless integration across various tools.

Language Server Protocol

The Language Server Protocol (LSP)1 was introduced by Microsoft in 2016 as a way to standardize communication between code editors (or IDEs) and language-specific tooling. Prior to LSP, every editor had its own method of integrating language support, leading to fragmentation, duplicated effort, and slower progress in tooling development. The following image summarizes the functionality of the LSP:

Figure 1: Language Server Protocol

Figure 1: Language Server Protocol

LSP solves these issues by decoupling the editor and the language tooling into two distinct components:

  1. Editor (Client): The interface that the developer interacts with (e.g., Emacs, Visual Studio Code, Neovim).
  2. Language Server: A standalone program that provides features such as autocompletion, type checking, navigation, and refactoring for a specific programming language.

These components communicate using JSON-RPC over a network or local connection. For example, the client might request “Provide autocompletions at line X, column Y”, and the server responds with suggestions.

In the context of Emacs and Python:

  • Language Server: Pyright2 (written in TypeScript) — the one of existing implementations of Language Servers for Python, — provides functionality like autocompletion, type checking, diagnostics, refactoring and code navigation. It works independently of the editors, communicating with clients via the standard LSP protocol.
  • Client: Packages such as lsp-mode3 implement the client side of the LSP protocol in Emacs. lsp-mode facilitate communication with the server (e.g., “find the definitions of the function”) and handling responses from the server (e.g., “here are the function definitions in file X, line Y”). lsp-mode also rendering output (e.g., error highlights, documentation tooltips).
  • Wrapper: In this guide, I will focus on the Pyright language server, with lsp-pyright4 as its wrapper. This package acts as an intermediary layer between lsp-mode and the Pyright server. It configures and launches the Pyright Node.js package as a language server. Additionally, lsp-pyright provides Python-specific configurations, such as setting up virtual environment paths, types, and modules. Essentially, it serves as a bridge between Emacs and Pyright.

In general, we get the following architecture:

  1. Emacs (lsp-mode): The client-side component. Sends requests to the server and displays the results.
  2. Emacs (lsp-pyright): The auxiliary layer. Handles the setup and configuration of Pyright.
  3. Node.js (Pyright): The server-side component. Processes requests, analyzes Python code, and returns the results to the client.

Let’s consider a small example that demonstrates how this architecture works:

  1. The user edits a Python file in Emacs.
  2. lsp-mode sends a request through lsp-pyright to the Pyright server: “What is the type of variable X?
  3. Pyright analyzes the code and returns the type of the variable.
  4. lsp-mode displays the result to the user in the minibuffer or on hover.

In this architecture LSP servers are editor-independent. Pyright can be used not only in Emacs but also in VS Code or Neovim. In other hand, lsp-pyright allows you to easily switch to another server (e.g., pylsp) without altering the client logic. And at the end, the code analysis is offloaded from the editor, as the server runs in a separate process.

Debug Adapter Protocol

Before Debug Adapter Protocol (DAP)5 was introduced, developers and teams creating IDEs and editors faced significant challenges in implementing debugging support. Each IDE had to individually integrate debugging for every programming language, leading to substantial duplication of effort and inefficiencies.

For instance, to support languages like Python, C++, and Java, IDE developers needed to integrate distinct debuggers such as gdb for C++ or pdb for Python. This required an understanding of the internal protocols for each debugger, resulting in:

  • Slow adoption of new languages in editors.
  • Lack of standardization: Each IDE or tool implemented its own debugging solutions.
  • Integration challenges with existing tools and ecosystems.

The following image visualizes the issue:

Figure 2: A lot of duplicated functionality without DAP.

Figure 2: A lot of duplicated functionality without DAP.

To address these problems, Microsoft introduced DAP while working on Visual Studio Code (VS Code). The concept was to provide a unified protocol for communication between IDEs and debuggers via a standardized API.

DAP was designed to solve several key issues:

  1. Reducing effort for debugging support across languages:
    • IDE developers need to implement support for DAP, not individual debuggers.
    • Language developers create a Debug Adapter to interface with their debugger, avoiding the need to support multiple IDEs.
  2. Standardization and universality:
    • Debuggers for any programming language can connect to any IDE supporting DAP.
  3. Modularity:
    • IDEs and debuggers can evolve independently. Updates to a debugger do not require IDE code changes.
  4. Remote debugging support:
    • DAP is designed to work both locally and remotely, simplifying integration with cloud and distributed environments.

The following image visualizes the solution:

Figure 3: DAP makes it possible to implement a single generic debugger UI per development tool and that Debug Adapters can be re-used across these tools.

Figure 3: DAP makes it possible to implement a single generic debugger UI per development tool and that Debug Adapters can be re-used across these tools.

DAP defines a JSON-based protocol for communication between two parties:

  • Editor (Client): For example, VS Code or Emacs sends commands such as “set a breakpoint” or “start execution”.
  • Server: A Debug Adapter for a specific debugger (e.g.,debugpy for Python) processes the commands and responds.

In the Emacs-Python ecosystem, the tools play the following roles:

  • Server: A Python debugger, such as debugpy6, acts as the server-side component. It processes requests from the DAP client (e.g., “start code execution”) and returns results (e.g., “current line of execution: 15, value of variable X: 42”). The server manages the entire debugging process, including:
    • Setting breakpoints.
    • Stepping through code.
    • Displaying the program’s current state.
  • Client: dap-mode7 serves as the client implementation of DAP in Emacs. It configures and manages debugging sessions, sends requests to the server (e.g., “add a breakpoint” or “execute the next step”), and presents information (call stacks, variable values, current execution line) to the user. dap-mode provides an interface for working with various language debuggers using the standardized DAP protocol.
  • Wrapper: dap-python (part of dap-mode package) acts as an intermediary layer between dap-mode and specific debuggers like debugpy. It simplifies Python-specific configurations such as:
    • Selecting the interpreter.
    • Passing script arguments.
    • Configuring source paths. This makes launching Python debuggers straightforward while adhering to the DAP standard.

In general, we get the following architecture:

  1. Emacs (dap-mode): Client-side component. Manages the debugging interface and sends requests to the server.
  2. Emacs (dap-python): Auxiliary layer. Configures the debugger server (e.g., debugpy) for Python.
  3. Debugger (debugpy): Server-side component. Executes debugging commands and provides data to the client.

Let’s consider a small example that demonstrates how this architecture works:

  1. The user initiates a debugging session for a Python script in Emacs via dap-mode.
  2. dap-mode, through dap-python, launches the debugger server (debugpy) with the specified configuration.
  3. dap-mode sends requests to the server:
    • Set a breakpoint at line 10”.
    • Run the script until the next breakpoint”.
  4. debugpy processes the requests:
    • Sets the breakpoint.
    • Runs the script, stopping at line 10.
    • Returns the current call stack and variable values.
  5. dap-mode displays this information to the user within Emacs.

In this architecture DAP servers are independent of editors. For example, debugpy can be used with VS Code, Emacs, or Neovim. In other hand, dap-python allows seamless switching between debuggers (e.g., using PyCharm’s debugger instead of debugpy). Debugging via DAP integrates naturally with other Emacs capabilities, such as lsp-mode, creating a cohesive development environment.

Integrating Components into a Unified Workflow

With a solid understanding of the foundational protocols (LSP and DAP), we can now explore how to integrate these tools into a cohesive development environment in Emacs. While each component serves a specific purpose, the true power of this setup lies in their seamless collaboration.

The following sections will demonstrate how these components work together to create an efficient Python IDE:

  • Code Intelligence (LSP): lsp-mode acts as the backbone of our configuration, providing diagnostics, navigation, and refactoring capabilities via Pyright, our chosen Python Language Server.
  • Real-time Feedback: flymake integrates with lsp-mode to ensure real-time linting and error highlighting during development.
  • Autocompletion: company leverages the capabilities of lsp-mode and yasnippet to provide intelligent code suggestions and template expansions.
  • Debugging (DAP): dap-mode and dap-python enable interactive debugging sessions, allowing you to set breakpoints, inspect variables, and step through code—all within Emacs.
  • Environment Management: direnv and envrc automate the activation of virtual environments, ensuring your Python projects remain consistent and reproducible.

By strategically combining these tools, we can achieve a powerful and flexible development setup that adapts to the demands of both small scripts and large-scale Python projects.

Each of these tools will be explained in the sections below, detailing their role in the workflow, their purpose within our configuration, and the minimal setup required to achieve a cohesive, functional result. However, this guide does not aim to provide an exhaustive configuration for each package. For deeper customization tailored to your specific needs, I strongly encourage referring to the official documentation of each tool.

Additionally, it is important to note that the tools discussed here are not always the only options available in their respective domains. Often, you can substitute one tool for another based on your preferences or project requirements. This guide should not be viewed as a rigid framework but rather as a practical and quick way to achieve a comprehensive Python IDE setup in Emacs.

Environment Setup

Installing Dependencies

Before diving into Emacs-specific configuration, ensure that your system is ready to support a robust Python development workflow. Fortunately, lsp-mode simplifies the process of setting up a Python Language Server like pyright. If the server isn’t already installed, lsp-mode automatically detects this and offers to install it for you.

Here’s what happens when you open a Python project without an installed server:

  1. lsp-mode displays a message in the minibuffer:
    Unable to find installed server supporting this file.
    The following servers could be installed automatically:
    
    You’ll be presented with a list of compatible servers to choose from.
  2. After selecting a server, in our case pyright, and pressing Enter, lsp-mode initiates the installation. For pyright, it runs something like this:
    /usr/bin/npm -g \
                 --prefix ~/.emacs.d/.cache/lsp/npm/pyright \
                 install pyright
    
    You can track progress in the buffer named like *lsp-install: /usr/bin/npm*. Once complete, the server connects to your project, and lsp-mode starts providing its features.
  3. In the *Messages* buffer, you’ll see something like this:
    LSP :: Download pyright started.
    Comint finished
    LSP :: Server pyright downloaded, auto-starting in 1 buffers.
    LSP :: Connected to [pyright:106136/starting ~/sampleproject].
    LSP :: pyright:106136 initialized successfully in folders: (~/sampleproject)
    

In rare cases where lsp-mode doesn’t offer to install pyright, or if the automatic process fails, you can install it manually. According to the official documentation, there are two main ways to install pyright:

  1. Install the Node.js package in the user’s home directory: This is the most official and feature-complete method. It provides a CLI application that seamlessly integrates with lsp-mode.
  2. Install the Python package: While this method might appeal to Python purists, I see several drawbacks:
    • If installed locally within a virtual environment, it adds yet another dependency to every project, which may not be ideal for workflows where dependencies are frequently removed and reinstalled for testing reproducibility.
    • Installing it in the user’s home directory feels redundant when a more official, Node.js-based alternative exists.

Given these considerations, I propose the installation in the user’s home directory via Node.js. Here’s how you can do the same:

# Install pyright in the user's home directory using npm
NPM_CONFIG_PREFIX="$HOME/.local/" npm install -g pyright

Don’t forget to add "$HOME/.local/bin" to your $PATH to make it easier for Emacs to find pyright. Without modifying your $PATH, it will still work, but you’ll need to do additional configuration in your LSP setup to specify the search path. For NPM_CONFIG_PREFIX usage trick during the installation with using -g see corresponding documentation page8.

I also suspect that in some Linux distributions or macOS, pyright might be available as a package through the system’s package manager. This could also work well, depending on your preferred setup.

Managing Multiple Pythons

Managing multiple Python versions is a necessity for many developers. Often, you don’t want to use the latest Python release, primarily due to package requirements or compatibility constraints. There are several approaches to managing multiple Python versions, and the choice largely depends on your environment and preferences.

For example, if you’re using Gentoo, Portage provides access to several older Python versions. Similarly, tools like Nix or Guix can be pinned to specific commits containing the desired Python version. However, for most users, the simplest and most flexible method is to use pyenv9.

pyenv allows you to install and manage multiple Python versions seamlessly. It also provides shims, which enable your shell to automatically select the appropriate Python version based on the current project directory. This functionality makes it a convenient tool for managing Python installations.

That said, in my workflow, I only use pyenv for installing and managing Python versions. For selecting the Python version on a per-project basis, I prefer to rely on direnv10, which I’ll describe next.

Using direnv

direnv is a language-agnostic tool that automatically enables or disables environment variables depending on the current directory (and its subdirectories). This approach eliminates the need to manually manage Python versions for each project and streamlines the setup process.

With direnv, you can configure a project’s Python environment simply by navigating into the corresponding directory. Unlike pyenv shims, which require additional configuration, direnv handles everything for you in a way that works not just for Python but for virtually any programming environment.

In fact there is an extensive Community Wiki11 that covers almost every setup you could want. I will not focus on the deep customization of direnv and show a basic example of how to set up direnv for a Python project:

Here’s a basic example of how to set up direnv for a Python project:

  1. At the root of your project, create a file named .envrc:

    export VIRTUAL_ENV=.venv
    layout python3
    
  2. Enable direnv for this directory by executing the following:

    direnv allow
    

From this point on, commands like command -v python or type -a python will point to the local Python version specified in your project.

The beauty of this workflow lies in its simplicity and flexibility:

  • pyenv ensures you can easily install any Python version you need.
  • direnv takes care of dynamically selecting the correct environment as you switch between projects, reducing the need for repetitive manual configuration.
  • direnv is language-agnostic, meaning it can be used not only for Python but for virtually any environment management scenario.

The example provided above is intentionally minimal and sufficient for the scope of this guide. However, direnv offers extensive customization options, including advanced configurations that integrate with pyenv shims. For more complex workflows, I highly recommend exploring the official documentation and experimenting with the examples available in the community wiki.

By combining pyenv and direnv, you gain a robust and scalable approach to managing multiple Python versions without the overhead of manual setup.

Integrating direnv with Emacs

There are 2 Emacs extensions I know of for direnv. There’s envrc12 and emacs-direnv13. Both are good and well-supported, but envrc sets environment variables buffer-locally. It automatically applies environment variables buffer-locally, ensuring project-specific configurations don’t leak into other buffers. This means I can open files in different projects, each with their own virtual env, and emacs will see different PATH etc. For each file I have open. This means I can run correctly isolated LSP sessions for each project.

Add this snippet to your Emacs configuration at the very bottom of your configuration:

(when (executable-find "direnv")
  (add-hook 'after-init-hook #'envrc-global-mode))

This activates envrc-mode for all programming modes, automatically aligning Emacs with the active environment. Please note, envrc-global-mode should be enabled after other global minor modes, since each prepends itself to various hooks.

IDE Setup

Autocompletion with company and lsp-mode

Autocompletion is a cornerstone of modern development workflows, saving countless keystrokes and providing real-time insights into your code. In this guide, autocompletion is achieved through the powerful combination of company14 and lsp-mode.

The setup for basic autocompletion is remarkably simple:

  1. Install company.
  2. Profit.

That’s it. No, seriously — this is not a joke. For the purposes of this guide (and honestly, for most use cases), you don’t even need to include (require 'company) in your Emacs configuration. By simply installing the package, autocompletion will work out of the box when combined with lsp-mode.

Diagnostics with flymake and flycheck

Emacs offers two prominent options for integrating diagnostics: flycheck15 and flymake16. Both packages excel in their respective domains, and choosing between them often depends on your specific needs and workflow. Both tools provide an interface for diagnostics suitable for our needs.

flycheck specializes in external linters, offering a rich ecosystem of integrations. Each language requires specifying the linters to use and configuring them as needed. flycheck maintains a comprehensive list of checkers (flycheck-checkers), making it good enough for workflows that heavily rely on external tools like Flake8, Mypy, or PyLint.

flymake focuses on modern language servers (LSP) and native integration with the Emacs ecosystem. It provides a unified API for connecting linters, particularly those compatible with LSP or standard formats like JSON or XML. flymake has been asynchronous since Emacs 26, making it more suitable for real-time feedback.

In practice, both tools perform exceptionally well with Python projects. I’ve personally tested each in various setups and found them reliable, efficient, and capable of handling complex diagnostics seamlessly. Neither is “better” than the other; instead, they complement different workflows.

That said, if you prefer flymake or don’t want to install any additional packages, lsp-mode supports both and will automatically default to whichever is available. The behavior of diagnostics is controlled by the lsp-diagnostics-provider variable, which is set to :auto by default. This means lsp-mode will prefer flycheck if it’s installed, falling back to flymake otherwise. This behavior is seamless and requires no additional configuration unless you explicitly disable lsp-auto-configure.

flycheck is straightforward to set up, especially for Python development. Begin by installing it via your preferred package manager. To enable it globally, add the following to your configuration:

(add-hook 'after-init-hook #'global-flycheck-mode)

For the purposes of this guide, global mode is unnecessary. Instead, I delegate lsp-mode to enable flycheck itself where needed, in buffers where lsp-mode is enabled.

You can navigate to the next/previous erroneous regions using flycheck-next-error and flycheck-previous-error or by using the corresponding keyboard bindings C-c ! n and C-c ! p.

flymake comes bundled with Emacs, requiring minimal setup. If you’re using lsp-mode, it will automatically enable flymake unless you explicitly configure it otherwise. For manual activation, add the following hook:

(add-hook 'python-mode-hook #'flymake-mode)

You can navigate to the next/previous erroneous regions using flymake-goto-next-error and flymake-goto-prev-error commands. It might be a good idea to map them to M-n and M-p in flymake-mode, by adding to your init file:

(with-eval-after-load 'flymake
  (define-key flymake-mode-map (kbd "M-n") 'flymake-goto-next-error)
  (define-key flymake-mode-map (kbd "M-p") 'flymake-goto-prev-error))

Flymake’s tight integration with LSP makes it an excellent choice for those who prefer a native Emacs experience. Recent improvements ensure smooth, asynchronous diagnostics without impacting performance.

Enhancing Autocompletion with yasnippet

To complement the power of lsp-mode and company, yasnippet17 provides ability to use predefined code snippets and templates for repetitive structures. This tool can save time and reduce boilerplate by allowing you to expand small triggers into full code blocks.

In this section, I’ll guide you through the minimal configuration necessary to get started with yasnippet. As always, I recommend exploring its capabilities further to tailor it to your specific needs, but here we’ll focus on the essentials.

Integrating yasnippet with company

After installing yasnippet, there’s no need to clutter your configuration with unnecessary options. Instead, let’s focus on a few key points. In the previous section, I mentioned how company works right out of the box. But frankly, we probably want autocomplete to use our snippets, don’t we?

Integrating yasnippet with company is straightforward. This would allow snippet expansion to be part of the autocompletion pipeline. Here’s how such an integration might look:

(with-eval-after-load 'company
  (add-to-list 'company-backends '(company-capf :with company-yasnippet)))

While the concept of company-backends is a bit more intricate (it’s essentially a list of functions implementing a specific interface), this simple addition suffices for our needs. This setup appends company-yasnippet to the company-capf backend, enabling snippet completions alongside LSP-driven suggestions. However, this modification globally affects all buffers where company is active, which might not be ideal for workflows requiring specific configurations for different modes.

For now, let’s defer configuring yasnippet as part of company-backends. Instead, we’ll revisit this integration in a later section where we craft a Python-specific setup. This approach allows us to maintain a modular configuration that can be tailored precisely to Python development needs.

Enabling yasnippet Globally?

Whether to enable yasnippet globally depends on your workflow. To enable it globally, simply call:

(with-eval-after-load 'yasnippet
  (yas-global-mode 1))

For the purposes of this guide, global mode is unnecessary. Instead, you can opt for a more targeted approach by reloading snippet tables and enabling yasnippet selectively in specific modes:

(with-eval-after-load 'yasnippet
  (yas-reload-all))

To enable yasnippet only in python-mode, lets define a custom function and add it to the relevant hook. We’ll reuse this hook a bit later for other python-related setiings.

(defun setup-python-environment ()
  "Custom configurations for Python mode."
  ;; Enable YASnippet mode
  (yas-minor-mode 1))

;; Configure hooks after `python-mode` is loaded.
(add-hook 'python-mode-hook #'setup-python-environment)

This approach keeps your configuration modular and avoids loading unnecessary modes globally.

Creating a Sample Snippet

Now that we have yasnippet set up, let’s create a simple snippet to see it in action. Create a file at ~/.emacs.d/snippets/python-mode/def with the following content:

# -*- mode: snippet -*-
# name: def
# key: def
# --
def ${1:function_name}(${2:args}):
    ${3:pass}

At this stage, I won’t delve into the syntax of snippets. You can experiment with it later to create more complex templates. For now, type def in a Python buffer and press TAB to expand it using yas-expand. Alternatively, you can type the first few characters, wait for lsp-mode to suggest def through company, and select it with TAB. It’s that simple.

The goal of this configuration is to get you started quickly. yasnippet integrates seamlessly with lsp-mode and company, and the minimal setup above is sufficient to demonstrate its potential. As you grow more familiar with it, you can explore more advanced snippet features and refine your workflow. For now, enjoy the simplicity of having code snippets readily available at your fingertips.

Set up LSP

This section covers one of the most exciting parts of the guide: configuring LSP for Python development in Emacs. With this setup, you’ll unlock roughly 70% of the features this guide aims to deliver, turning Emacs into a highly capable Python IDE.

Getting Started

To begin, ensure you have the following packages installed: lsp-mode, lsp-pyright, and lsp-ui18. These three packages form the backbone of our LSP configuration.

Configuring the Keymap Prefix

One of the first and most helpful configurations is setting a keymap prefix for lsp-mode. If you struggle with remembering multiple keybindings or simply prefer an accessible overview of available commands, I recommend install and use the which-key19 package.

which-key is a minor mode that displays keybinding suggestions in a popup as you type a prefix. When integrated with lsp-mode, it provides a quick reference for all LSP-related commands.

By default, the lsp-mode keymap prefix is s-l (Super + L), which often conflicts with system-wide shortcuts, such as screen locking on many Linux desktop environments. To avoid this, I recommend changing it to C-c l, even though it conflicts with org-store-link in org-mode. Personally, I find the excessive use of C-c <key> bindings for so many commands in org-mode to be unjustified.

As stated in the manual:

A small number of keys are reserved for user-defined bindings, and should not be used by modes, so key bindings using those keys are safer in this regard. The reserved key sequences are those consisting of C-c followed by a letter (either upper or lower case), and function keys F5 through F9 without modifiers.

Here’s how you can remap the prefix and reassign org-store-link:

; Reassign org-store-link
(global-set-key (kbd "C-c o l") #'org-store-link)

(setopt lsp-keymap-prefix "C-c l")

Selecting the Correct Python Version

lsp-pyright automatically attempts to locate the appropriate Python executable for the current project using a series of search functions. The default behavior includes:

  1. Searching for a virtual environment named .venv or venv in the parent directories.
  2. Looking for a Python executable in your system’s PATH.

However, you can extend or customize this behavior. Earlier in this guide, we configured direnv to set the VIRTUAL_ENV environment variable. Let’s leverage this by adding a custom search function:

(defun my-locate-python-virtualenv ()
  "Find the Python executable based on the VIRTUAL_ENV environment variable."
  (when-let ((venv (getenv "VIRTUAL_ENV")))
    (let ((python-path (expand-file-name "bin/python" venv)))
      (when (file-executable-p python-path)
        python-path))))

(with-eval-after-load 'lsp-pyright
  (add-to-list 'lsp-pyright-python-search-functions
               #'my-locate-python-virtualenv))

This addition ensures lsp-pyright prioritizes the Python executable specified by VIRTUAL_ENV, speeding up its decision-making process. This environemt was set by direnv and proxied to Emacs by envrc mode. While optional, this example demonstrates the simplicity and flexibility of extending lsp-pyright. We will not use this hook function in our example, it was just an example of how to extend lsp-pyright-python-search-functions.

At this point, you might think about explicitly setting variables such as python-shell-interpreter or others provided by different packages, pointing them to the Python executable. Technically, you could add something like this to your hook function:

(defun setup-python-environment ()
  "Custom configurations for Python mode."
  ;; Enable YASnippet mode
  (yas-minor-mode 1)

  ;; Set the `python-shell-interpreter' to the python in PATH.
  ;; At this moment `envrc' should successfully configure environment.
  (setq-local python-shell-interpreter (executable-find "python")))

And this might even work. However, from my perspective, it’s better not to touch the Python paths — neither here nor in any other variable within this hook function — and instead leave them as they are, relative. Tools like envrc and others (discussed later) excel at determining the base directory and environment upon switching to a Python project.

The simplest way to verify that everything is working as expected is to open a Python project with a virtual environment managed by direnv. Then, invoke the command M-x run-python, and in the resulting Python interpreter buffer, execute the following code:

>>> import sys
>>> print(sys.executable)

You should see output resembling this:

~/sampleproject/.venv/bin/python

Now, if you close the interpreter buffer, switch to a new Python project with a virtual environment managed by direnv, and repeat M-x run-python and the code above, you will notice that the path to Python changes. This dynamic adjustment indicates that everything is configured correctly.

By avoiding manual intervention, you keep your configuration minimal and less error-prone, while still allowing lsp-pyright and related tools to do their job seamlessly. In this spirit, I recommend refraining from setting python-shell-interpreter or any similar variable from other packages manually and letting the ecosystem handle it automatically.

Finally, if you want the LSP server to shut down after you close all buffers associated with the server, set lsp-keep-workspace-alive to nil.

Configuring LSP Completion

In Emacs, the current trend for completion logic is to leverage the completion-at-point-functions (CAPF) framework. This is a standardized approach to completion that integrates with many major modes and external tools, including those that support the LSP.

CAPF serves as the backbone for modern Emacs completion systems. It provides a unified interface for completion suggestions and works seamlessly with LSP-based tools. company-capf acts as a bridge between the company-mode autocompletion system and the CAPF framework, making it a popular and highly recommended backend for autocompletion.

By default, the company-backends variable is configured as follows:

(company-bbdb company-semantic company-cmake company-capf company-clang company-files
              (company-dabbrev-code company-gtags company-etags company-keywords)
              company-oddmuse company-dabbrev)

This setup includes a wide range of backends, with company-capf already present. However, this default configuration may introduce redundant or less relevant backends for your use case, especially when working with LSP.

To streamline the autocompletion process, you can customize company-backends to prioritize CAPF and reduce clutter from other backends. Here’s a minimal configuration example that will work for LSP-based workflows:

(setopt company-backends '((company-capf company-dabbrev-code)))

This setup is almost sufficient for many major modes that implement CAPF support, including Python via lsp-mode. But lets a bit improve this configuration by adding yasnippet support. To improve performance and maintain clarity, we’ll define a modular and reusable approach to backend configuration using a macro. Here’s how:

(defmacro company-backend-for-hook (hook backends)
  `(add-hook ,hook (lambda ()
                     (set (make-local-variable 'company-backends)
                          ,backends))))

This macro dynamically configures company-backends for any mode by attaching it to a specified hook. Using this approach ensures reusability and consistency across different languages and modes.

Now, we impove our setup-python-environment hook function:

(defun setup-python-environment ()
  "Custom configurations for Python mode."
  ;; Enable YASnippet mode
  (yas-minor-mode 1)

  ;; Customize company backends for `python-mode'.
  (company-backend-for-hook 'lsp-completion-mode-hook
                        '((company-capf :with company-yasnippet)
                          company-dabbrev-code)))

Here’s explanation:

  • lsp-completion-mode-hook: Hook run after entering lsp-completion-mode
  • company-capf: Handles LSP-driven suggestions via CAPF.
  • company-yasnippet: Seamlessly integrates snippet expansion.
  • company-dabbrev-code: Acts as a fallback for suggestions based on buffer content.
  • :with helps to make sure the results from mode agnostic backends (such as company-yasnippet) are returned without preventing results from context-aware backends (such as company-capf).

CAPF support is enabled by default in lsp-mode. The lsp-completion-provider is already set to :capf, ensuring seamless integration with company-capf. No additional configuration is required for this functionality.

At the end, we need to enable LSP support in Python buffers, by enbling lsp-mode as well as lsp-pyright. As stated in documentation all we need is to require lsp-pyright and call lsp (or lsp-deferred)function. Let’s extend our hook function for python-mode:

(defun setup-python-environment ()
  "Custom configurations for Pyton mode."
  ;; Enable YASnippet mode
  (yas-minor-mode 1)

  ;; Setup active backends for `python-mode'.
  (company-backend-for-hook 'lsp-completion-mode-hook
                          '((company-capf :with company-yasnippet)
                            company-dabbrev-code))

  ;; Enable LSP support in Python buffers.
  (require 'lsp-pyright)
  (lsp-deferred))

The difference between lsp and lsp-deferred function is that lsp-deferred will start the server when you switch to that buffer (on demand).

Enhancing the UI

Modern development workflows benefit from visual cues that provide context about the current file, project, and code structure. In Emacs, this is achieved through breadcrumb navigation and other enhancements provided by lsp-mode and lsp-ui.

Breadcrumb navigation in the headerline helps track the current file’s location within a project, along with symbols at the current point. This feature is part of lsp-mode and is enabled via the lsp-headerline-breadcrumb-mode. You can customize the breadcrumb segments displayed by modifying the lsp-headerline-breadcrumb-segments variable.

For instance:

(setopt lsp-headerline-breadcrumb-segments
        '(path-up-to-project  ; Include directories up to the project root
          file                ; Include the open file name
          symbols))           ; Include document symbols, if supported

To activate breadcrumb navigation, no additional steps are required unless you explicitly disable lsp-auto-configure. Otherwise you have to enable it as follows:

(add-hook 'lsp-mode-hook #'lsp-headerline-breadcrumb-mode)

The lsp-ui package augments the LSP experience with additional UI modules, such as:

  • Sideline Diagnostics: Display inline diagnostics for the current line.
  • Peek Definitions: A dedicated cross-references popup.
  • Doc Frames: Show documentation in a floating frame near the cursor.

By default, lsp-mode automatically activates lsp-ui unless lsp-auto-configure is set to nil. All you need to enable it is to install it.

You can customize lsp-ui features20 to match your preferences. All of this is optional and secondary of course. I suggest playing around with these parameters to determine what works best for you.

One highly recommended adjustment is remapping xref-find-definitions and xref-find-references, which are bound to M-. and M-? by default. These commands open definitions or references in a new buffer, which can interrupt your workflow. With lsp-ui-peek, you can view definitions and references in a popup, allowing you to navigate cross-references without leaving your current context. Here’s how to remap the keys:

(with-eval-after-load 'lsp-ui
  ;; Remap `xref-find-definitions' (bound to M-. by default)
  (define-key lsp-ui-mode-map
              [remap xref-find-definitions]
              #'lsp-ui-peek-find-definitions)

  ;; Remap `xref-find-references' (bound to M-? by default)
  (define-key lsp-ui-mode-map
              [remap xref-find-references]
              #'lsp-ui-peek-find-references))

Integrating which-key with lsp-mode

To further enhance the usability of lsp-mode, enable its built-in integration with which-key:

(with-eval-after-load 'lsp-mode
  (add-hook 'lsp-mode-hook #'lsp-enable-which-key-integration))

This integration ensures that pressing the LSP keymap prefix (C-c l in our case) provides a helpful list of available commands. Of course, you need to have the which-key package installed to take full advantage of this configuration. However, this setup is optional, and you can skip it if you’re not a which-key user or don’t understand its purpose. Strictly speaking, this configuration is not a defining feature of an IDE.

LSP Sessions and Worspaces

One of the final note for LSP core setup is regarding the variable lsp-keep-workspace-alive, which controls whether the LSP server remains active after the last workspace buffer is closed. By default, this variable is set to t, meaning the server continues running even after you close the last buffer associated with the server. You can verify this by running the following command after closing all project-related buffers:

ps -eo args | grep '[p]yright-langserver'

While I understand the cases where keeping the server alive might be beneficial, I find it inconvenient for my typical workflows. For this reason, I prefer to disable this behavior. This is not a mandatory configuration but rather a reflection of my preferences and habits. So, for the purposes of this guide, however, I’ll simply disable it:

;; Shut down LSP server after close all buffers associated with the server.
(setopt lsp-keep-workspace-alive nil)

One of the more intriguing capabilities of the LSP is its support for Multi-Root sessions. This feature allows a single language server instance to handle multiple projects (workspace folders) within the same session. It’s particularly useful in scenarios like working with monorepos or related projects where shared context between folders can enhance development efficiency.

In the case of lsp-pyright, the variable lsp-pyright-multi-root determines whether Pyright is permitted to serve multiple root folders within a single server instance. This behavior is enabled by default, meaning Pyright starts in Multi-Root mode and adds all relevant projects to the current session.

While Multi-Root mode has clear advantages, it also introduces potential downsides:

  • Pyright will load all specified directories in the session, even if you’re actively working on only one project. For large projects, this can significantly increase the initialization time of the LSP process, leading to slower startup and responsiveness.
  • The server may index directories unrelated to the current task, consuming resources like CPU and memory without adding immediate value.
  • Common configurations, such as the Python virtual environment, are automatically applied across related projects. This means if you open two Python projects, each with its own virtual environment, the LSP server will use only one of them for both projects. This can lead to:
    • Tools like flycheck or flymake might show errors or warnings based on the wrong set of dependencies or library versions.
    • Projects using different versions of the same library may cause the server to analyze the wrong version, leading to false positives or missed issues.

In my typical Python workflow, I rarely work with monorepos or tightly coupled projects that benefit from shared contexts. Instead, I prefer to open separate instances of Emacs for each project, allowing each one to spawn its own LSP server. My workstation has enough resources (ample memory and a powerful CPU) to handle multiple Python projects and LSP server instances simultaneously without noticeable performance degradation.

To disable Multi-Root mode in lsp-pyright, you need to configure it before the package is loaded:

;; This must be set before the package is loaded.
(setq lsp-pyright-multi-root nil)

;; Enable LSP support in Python buffers.
(require 'lsp-pyright)
(lsp-deferred)

Once you disable Multi-Root mode, it’s essential to clean up the session data to ensure the changes take effect. Remove the file ~/.emacs.d/.lsp-session-v1 (is set by lsp-session-file) and restart Emacs or LSP mode. After this, you can verify the behavior by opening two separate Python projects and running the command M-x lsp-describe-session. This will display:

  • You should see that only the relevant directory for each project is listed.
  • Each project should have its own server process, identifiable by its unique Process ID (PID).

This setup ensures that each project operates independently, with its own configuration, dependencies, and diagnostics, aligning perfectly with a workflow where isolation between projects is essential condition.

While Multi-Root mode has its merits in specific use cases, such as monorepos, it doesn’t suit every workflow, in particular my own. By disabling it, you gain finer control over server behavior and avoid unnecessary cross-project interference. If you’re curious to explore its benefits or adjust your setup for a different workflow, I encourage you to delve deeper into the LSP documentation on Workspaces and Sessions to make an informed decision.

LSP Extensions

This section highlights optional extensions to the standard LSP setup, reflecting my preferences and demonstrating how you can enhance your workflow. Everything described here is entirely optional and depends on your habits and needs. Nothing is mandatory.

For users of consult, the consult-lsp21 package bridges the gap with lsp-mode, providing functionality similar to what Helm and Ivy users enjoy. Below are a couple of features I’ve found particularly useful, which you can use as inspiration for further exploration.

By default, lsp-mode provides the lsp-treemacs-errors-list command for querying diagnostics. While functional, I find consult-lsp-diagnostics more intuitive and aligned with the consult workflow. Here’s how to remap the diagnostic list command:

(with-eval-after-load 'lsp-mode
  (define-key lsp-mode-map
              [remap lsp-treemacs-errors-list]
              #'consult-lsp-diagnostics))

Now, pressing C-c l g e will bring up diagnostics in the familiar consult format, while lsp-treemacs-errors-list remains accessible for manual invocation if needed. Also, if you are flymake user the commands flymake-show-buffer-diagnostics and flymake-show-project-diagnostics are designed for the same purposes.

Another powerful feature of consult-lsp is consult-lsp-symbols, which I find more natural than xref-find-apropos. It provides an intuitive interface for searching all meaningful symbols that match a given pattern:

(with-eval-after-load 'lsp-mode
  (define-key lsp-mode-map
              [remap xref-find-apropos]
              #'consult-lsp-symbols))

Now, pressing C-c l g a allows you to find symbols effortlessly in the consult interface. Again, xref-find-apropos remains accessible for manual invocation if needed.

These are just two examples of how consult-lsp can integrate into your workflow. The package offers many more commands and features worth exploring. I encourage you to dive deeper and experiment with its capabilities to see what works best for you.

Debugging with dap-mode

Debugging in Emacs is made seamless with dap-mode, a powerful extension that integrates the DAP into your workflow. This section provides a gentle introduction to setting up dap-mode for Python projects, showcasing how minimal configuration can get you started quickly.

Setting Up dap-mode

To begin, you’ll need to install and enable dap-mode. For a straightforward setup, you can delegate most of the configuration to the package itself by enabling the auto-configuration mode:

(setopt dap-auto-configure-mode t)

Or if you want to enable only specific modes instead:

(require 'dap-mode)

;; The modes below are optional

;; Displaying DAP visuals.
(dap-ui-mode 1)

;; Enables mouse hover support
(dap-tooltip-mode 1)

;; Use tooltips for mouse hover if it is not enabled `dap-mode' will
;; use the minibuffer.
(tooltip-mode 1)

;; Displays floating panel with debug buttons requies emacs 26+.
(dap-ui-controls-mode 1)

Configuring dap-python

After enabling dap-mode on Emacs side we need to tune up the Python specific settings, in our case dap-python. First of all you need to install the debugging tool that dap-python will use. For Python, two common options are debugpy22 and the deprecated ptvsd23. I recommend using debugpy as it is more advanced and actively maintained. Install it in your project’s virtual environment with:

pip install debugpy

and to install ptvsd instead of debugpy us the following command:

pip install "ptvsd>=4.2"

Note, ptvsd is depracated, and as of 8/10/2022, ptvsd caused dap to break when it hits a breakpoint. For more details, refer to the related discussion in dap-mode issues tracker24.

To enable debugging for Python buffers, you’ll need to load the dap-python module and if you’re using debugpy, make sure to set it as the debugger explicitly.

One important detail: for dap-mode to function correctly, ensure that lsp-mode is active in the buffer you’re trying to debug. Without it, some necessary variables won’t be initialized, causing issues when launching the debugger.

Let’s extend our hook function for python-mode:

(defun setup-python-environment ()
  "Custom configurations for Pyton mode."
  ;; Enable YASnippet mode
  (yas-minor-mode 1)

  ;; Setup active backends for `python-mode'.
  (company-backend-for-hook 'lsp-completion-mode-hook
                          '((company-capf :with company-yasnippet)
                            company-dabbrev-code))

  ;; Enable LSP support in Python buffers.
  (require 'lsp-pyright)
  (lsp-deferred)

  ;; Enable DAP support in Python buffers.
  (require 'dap-python)
  (setq-local dap-python-debugger 'debugpy)

  (dap-mode 1))

This configuration adds Python-specific debugging templates to dap-debug, making it easy to get started.

Using dap-mode

Once your setup is complete, open a Python project, then any python file and call the dap-debug command. You’ll be prompted to choose a debugger configuration template. For Python, the following templates are provided by default (via dap-python):

  • Run file (buffer): Executes the currently opened buffer.
  • Run pytest (buffer): Runs all tests in the current buffer.
  • Run pytest (at point): Runs the test at point.
  • Attach to running process: Attaches to an already running Python process.
  • Run file from project directory: Executes a file from the project root.

A common use case is selecting Python :: Run file (buffer) to run the script in the current buffer. Note that this configuration doesn’t handle arguments, environment variables, or complex setups like executing a setuptools-based script. For such needs, you’ll have to create a custom template. To verify your setup, choose Python :: Run file (buffer) and launch the debugger. The script will execute, and you’ll notice additional windows appear in Emacs, indicating the debugger is active. However, since no breakpoints are set by default, the debugger will likely finish execution immediately. Even so, this confirms that the integration is working as intended.

Creating a Custom Debug Template

While the default templates in dap-python cover common use cases, there are scenarios where you need a tailored setup to handle arguments, environment variables, or specific entry points. Creating a custom debug template allows you to address these needs effectively.

Start by creating an Emacs Lisp file to hold your custom template. For consistency and collaboration, I recommend create debug.el and storing this file in your project repository. This ensures that your teammates can benefit from the same templates. Let’s assume you’re working on a Python project with the following structure:

.
├── .envrc
├── .venv/
├── myapp
│   ├── cli.py
│   ├── __init__.py
│   └── __main__.py
└── tests/

In the root of this project (on the same level as tests and myapp), create a file named debug.el with the following content:

(dap-register-debug-template
"My Project :: Console App"
 (list :type "python"
       :request "launch"
       :name "My Project :: Console App"
       :program "${workspaceFolder}/myapp/__main__.py"
       :cwd "${workspaceFolder}"
       :args '("--some-arg" "value" "some-positional-arg")
       :env '(("DEBUG" . "1") ("LOG_LEVEL" . "debug"))))

Here’s a breakdown of the used fields:

  • :type: Specifies the DAP adapter type.
  • :request: Indicates the request type launch or attach.
  • :name: The name of the template, as it will appear in the dap-debug command.
  • :program: Absolute path to the entry point of your application.
  • :cwd: Sets the working directory to the project root.
  • :args: Example of command line arguments passed to the entrypoint.
  • :env: Example of environment variables defined as a key value pair.

This is not a full list of the fields. Just a possible working example. For a detailed description of possible fields refer to documentation for used debugger. For debugpy there is a Wiki page25.

To use the custom template the buffer content using M-x eval-buffer. Your template is now registered and can be selected when invoking dap-debug.

To verify your template:

  1. Open your Python project in Emacs.
  2. Run dap-debug and select “My Project :: Console App” from the list of templates.
  3. The debugger should start, and you’ll see additional windows such as breakpoints, locals, and output.

While manually evaluating the file works, automating the process ensures that the template is loaded whenever the project is opened. You could write a hook function that checks for the existence of debug.el in the project root and evaluates it automatically. For now, I leave this as an exercise for you.

By following these steps, you can create highly specific debug configurations tailored to your project’s needs. Custom templates like this not only streamline your debugging workflow but also make it easier for teammates to get started with consistent settings. Debugging doesn’t have to be a chore — let dap-mode and a bit of Emacs Lisp handle the heavy lifting for you.

Setting and Managing Breakpoints

To add breakpoints, use dap-breakpoint-add or dap-breakpoint-toggle. These commands are intuitive: simply place your cursor on the desired line and invoke the function. Once added, a small dot will appear, indicating the breakpoint. To quickly re-run the debugger with the last configuration, use dap-debug-last. This command saves you from re-selecting the configuration. If a breakpoint is hit, the debugger will pause execution and open additional windows:

  • Breakpoints: A list of all active breakpoints, with options to manage them.
  • Locals: Displays variables local to the current stack frame.
  • Expressions: Allows you to monitor custom expressions.
  • Debug Sessions: Tracks active debug sessions.
  • Run file (buffer): Displays output and interaction for the current session.
Figure 4: Example of dap-mode interface in Emacs

Figure 4: Example of dap-mode interface in Emacs

These windows provide a comprehensive view of the program’s state. You can even navigate the call stack using dap-switch-stack-frame in addition to interact with specific frames through the stack window.

To terminate the debugging session, invoke dap-disconnect.

Essential Commands

Here’s a quick reference to the most commonly used commands:

  • dap-debug: Start a debug session.
  • dap-debug-last: Restart the last debug session.
  • dap-breakpoint-toggle: Add or remove a breakpoint.
  • dap-breakpoint-delete: Remove all breakpoints.
  • dap-step-in, dap-step-out, dap-next: Control execution flow.
  • dap-switch-stack-frame: Switch between stack frames.
  • dap-disconnect: End the debugging session.

This introduction demonstrates how easy it is to integrate dap-mode into Emacs for Python debugging. While I haven’t delved into creating complex custom templates or advanced debugging scenarios, I encourage you to explore the dap-mode documentation to unlock its full potential.

My goal here was to show that setting up and integrating dap-mode is straightforward and doesn’t require deep expertise upfront. With minimal effort, you can start experimenting and gradually build a debugging workflow tailored to your needs.

Bringing It All Together

Here’s the complete setup:

(with-eval-after-load 'yasnippet
  (yas-reload-all))

;; Only if you use `flymake-mode'. For `flycheck-mode' replace commands
;; by `flycheck-next-error' and `flycheck-previous-error'.
(with-eval-after-load 'flymake
  (define-key flymake-mode-map (kbd "M-n") 'flymake-goto-next-error)
  (define-key flymake-mode-map (kbd "M-p") 'flymake-goto-prev-error))

;; Set LSP keymap prefix.
(setopt lsp-keymap-prefix "C-c l")

;; Shut down LSP server after close all buffers associated with the server.
(setopt lsp-keep-workspace-alive nil)

;; Configure LSP UI enhancements.
(setopt lsp-headerline-breadcrumb-segments
        '(path-up-to-project
          file
          symbols))

(with-eval-after-load 'lsp-ui
  ;; Remap `xref-find-definitions' (bound to M-. by default).
  (define-key lsp-ui-mode-map
              [remap xref-find-definitions]
              #'lsp-ui-peek-find-definitions)

  ;; Remap `xref-find-references' (bound to M-? by default).
  (define-key lsp-ui-mode-map
              [remap xref-find-references]
              #'lsp-ui-peek-find-references))

;; Configure LSP mode for enhanced experience.
(with-eval-after-load 'lsp-mode
  ;; Remap `lsp-treemacs-errors-list' (bound to C-c l g e).
  (define-key lsp-mode-map
              [remap lsp-treemacs-errors-list]
              #'consult-lsp-diagnostics)

  ;; Remap `xref-find-apropos' (bound to C-c l g a).
  (define-key lsp-mode-map
              [remap xref-find-apropos]
              #'consult-lsp-symbols)

  ;; Enable `which-key-mode' integration for LSP.
  (add-hook 'lsp-mode-hook #'lsp-enable-which-key-integration))

;; Auto configure dap minor mode.
(setopt dap-auto-configure-mode t)

(defmacro company-backend-for-hook (hook backends)
  `(add-hook ,hook (lambda ()
                     (set (make-local-variable 'company-backends)
                          ,backends))))

(defun setup-python-environment ()
  "Custom configurations for `python-mode'."
  ;; Enable YASnippet mode.
  (yas-minor-mode 1)

  ;; Setup active backends for `python-mode'.
  (company-backend-for-hook 'lsp-completion-mode-hook
                            '((company-capf :with company-yasnippet)
                              company-dabbrev-code))

  ;; Prevent `lsp-pyright' start in multi-root mode.
  ;; This must be set before the package is loaded.
  (setq-local lsp-pyright-multi-root nil)

  ;; Enable LSP support in Python buffers.
  (require 'lsp-pyright)
  (lsp-deferred)

  ;; Enable DAP support in Python buffers.
  (require 'dap-python)
  (setq-local dap-python-debugger 'debugpy)

  (dap-mode 1))

;; Configure hooks after `python-mode' is loaded.
(add-hook 'python-mode-hook #'setup-python-environment)

;; Setup buffer-local direnv integration for Emacs.
(when (executable-find "direnv")
  ;; `envrc-global-mode' should be enabled after other global minor modes,
  ;; since each prepends itself to various hooks.
  (add-hook 'after-init-hook #'envrc-global-mode))

With this configuration, company, yasnippet, lsp-mode and dap-mode work seamlessly together to provide an enhanced development experience. You now have powerful tools for Python development at your disposal, with minimal effort and plenty of room for customization.

Future Enhancements

For those who prefer minimal configurations, Eglot26 offers a lightweight alternative to lsp-mode. It integrates seamlessly with Emacs’s native features and emphasizes simplicity over extensibility. Eglot is particularly suitable for users who value a straightforward setup and are willing to trade some advanced features for ease of use. Its minimalistic design makes it an appealing choice for new users or those with modest LSP needs.

If performance is your top priority, consider lsp-bridge27. It aims to be the fastest LSP client in the Emacs ecosystem by leveraging multi-threading technology. lsp-bridge follows a plug-and-play philosophy, reducing setup time and effort. This client might appeal to users working on large codebases where performance bottlenecks in other clients become apparent.

For Python developers, the Jedi Language Server28 is a focused alternative. Built exclusively on the Jedi library, it provides completions, definitions, references, and other essential features. If your Python workflow already relies on Jedi, this server integrates seamlessly and offers a lightweight option compared to more comprehensive language servers.

Another Python-specific option is the Python LSP Server29. This community-maintained fork of the python-language-server (pylsp) project is backed by the Spyder IDE team and the broader Python community. It supports Python 3.8+ and provides robust LSP features like completions, hover, and references. By default, it uses Jedi but can be extended with plugins for additional functionality.

I discovered uv30 too late in my journey, but I can confidently say it has the potential to make some of the tools described earlier unnecessary. uv is a versatile, extremely fast Python package and project manager, written in Rust. It combines and enhances the functionalities of tools like pip, pip-tools, pipx, pyenv, and virtualenv into a single utility. With uv, you can install and manage Python versions, manage dependencies, create virtual environments, and even run Python scripts with inline dependency metadata. It also supports building and publishing Python projects with an efficient universal lockfile.

Conclusion

So, we’ve reached the end of this guide. If you’ve followed along and set everything up, your system should now include the following components (organized by their location):

Component Location
pyenv System-wide or User Home Directory.
direnv System-wide or User Home Directory.
pyright User Emacs Directory or User Home Directory.
envrc Emacs Package.
company Emacs Package.
lsp-mode Emacs Package.
lsp-pyright Emacs Package.
lsp-ui Emacs Package.
yasnippet Emacs Package.
dap-mode Emacs Package.
flycheck Emacs Package (optional, you can use flymake).
which-key Emacs Package (optional).
consult-lsp Emacs Package (optional, only if consult is installed).
debugpy Project Virtual Environment.

The optional components are just that — optional. You can skip them and still achieve 99% of what’s described in this guide. The rest, however, are highly recommended. Are these tools unique or the absolute best? It’s hard to say, but they’re undeniably excellent, and their combination transforms Emacs into a fully-fledged Python IDE.

Should you stop here? Absolutely not. There are countless other tools and configurations that can make your Python development workflow even smoother and more enjoyable. I haven’t covered all of them because this article was never intended to be an encyclopedia.

That said, I genuinely enjoy this setup. Not only does it work wonderfully for Python, but the combination of direnv and lsp-mode integrates seamlessly with virtually every programming language and environment. What’s particularly satisfying is how little effort is required to adapt it to new languages — no need to bloat your Emacs configuration with language-specific extensions.

In most cases, all you need to do is install the appropriate language server and, if necessary, a lightweight integration library like we did with lsp-pyright. This modular and universal approach highlights the true power of Emacs: flexibility, simplicity, and the ability to evolve alongside your workflow. Whether you’re working with Python, JavaScript, C++, or even niche languages, this setup delivers a clean and powerful development experience.

References


  1. Language Server Protocol homepage. https://microsoft.github.io/language-server-protocol/ ↩︎

  2. Pyright homepage. https://microsoft.github.io/pyright/#/ ↩︎

  3. Emacs client/library for the Language Server Protocol. https://emacs-lsp.github.io/lsp-mode/ ↩︎

  4. lsp-mode client leveraging pyright and basedpyright Language Servers. https://emacs-lsp.github.io/lsp-pyright/ ↩︎

  5. Debug Adapter Protocol homepage. https://microsoft.github.io/debug-adapter-protocol/ ↩︎

  6. An implementation of the Debug Adapter Protocol for Python. https://github.com/microsoft/debugpy ↩︎

  7. Emacs client library for Debug Adapter Protocol. https://github.com/emacs-lsp/dap-mode ↩︎

  8. Resolving EACCES permissions errors when installing packages globally. https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally ↩︎

  9. Simple Python version management. https://github.com/pyenv/pyenv ↩︎

  10. direnv home page. https://direnv.net/ ↩︎

  11. direnv Community Wiki. https://github.com/direnv/direnv/wiki/Python ↩︎

  12. Emacs support for direnv which operates buffer-locally. https://github.com/purcell/envrc ↩︎

  13. Emacs support for direnv. https://github.com/wbolster/emacs-direnv ↩︎

  14. Modular in-buffer completion framework for Emacs. https://company-mode.github.io/ ↩︎

  15. Syntax checking for GNU Emacs. https://www.flycheck.org/en/latest/ ↩︎

  16. GNU Flymake. https://www.gnu.org/software/emacs/manual/html_node/flymake/index.html ↩︎

  17. A template system for Emacs. https://github.com/joaotavora/yasnippet ↩︎

  18. UI integrations for lsp-mode. https://emacs-lsp.github.io/lsp-ui/ ↩︎

  19. Display available keybindings in popup. https://elpa.gnu.org/packages/which-key.html ↩︎

  20. lsp-ui features and configuration. https://emacs-lsp.github.io/lsp-ui/#intro ↩︎

  21. lsp-mode and consult helping each other. https://github.com/gagbo/consult-lsp ↩︎

  22. An implementation of the Debug Adapter Protocol for Python. https://github.com/microsoft/debugpy ↩︎

  23. Python debugger package for use with Visual Studio and Visual Studio Code. https://github.com/microsoft/ptvsd ↩︎

  24. dap-mode Issues #625. https://github.com/emacs-lsp/dap-mode/issues/625#issuecomment-1128961454 ↩︎

  25. debugpy launch/attach configuration settings. https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings ↩︎

  26. Eglot - A client for Language Server Protocol servers. https://joaotavora.github.io/eglot/ ↩︎

  27. A fast LSP client for Emacs. https://github.com/manateelazycat/lsp-bridge ↩︎

  28. A Python language server exclusively for Jedi. https://github.com/pappasam/jedi-language-server ↩︎

  29. Fork of the python-language-server project. https://github.com/python-lsp/python-lsp-server ↩︎

  30. An extremely fast Python package and project manager, written in Rust. https://github.com/astral-sh/uv ↩︎