mrkeyoor.com_
Thu 06 Aug 01:02 UTC
PyPICLI & Toolingupdated 05 Aug 2026

prompt-toolkit

prompt_toolkit is a pure Python replacement for GNU readline, and quite a bit more. At the small end you call prompt('> ') and get a line editor with history, emacs or vi keybindings, incremental search, and correct handling of wide characters and bracketed paste. At the large end it is a full terminal UI framework: buffers, layouts, key binding registries, filters, and an asyncio Application loop, which is what IPython, ptpython, pgcli, and a long list of database and cloud shells are built on. It carries no global state, so several independent prompts can live in one process, and its only hard dependency is wcwidth.

Verdict

The only serious option in Python when you need a real line editor or a custom REPL, and the reason every good Python database shell feels the same. For a handful of questions it is overkill, and for a full-screen widget app textual will get you there faster.

API stability5/53.0 landed in 2019 and the public surface has only grown since; recent releases add opt-in shortcuts such as choice() and frame=, and the biggest 3.0.53 change was raising the Python floor to 3.10. Code written against 3.0 years ago still runs.
Docs4/5readthedocs has a real tutorial, a reference, and an asyncio section, and the repo carries roughly forty single-purpose examples under examples/prompts. The full-screen application side is thinner and often sends you to read the source, which the project openly recommends.
Maintenance3/5Pushed July 2026 with a 3.0.53 release that added Python 3.14 and 3.15 support, so it is clearly alive. But 610 open issues (705 counting PRs) and a bus factor of essentially one mean niche terminal problems tend to sit.
Ecosystem5/5IPython, ptpython, pgcli, mycli, and many vendor CLIs build on it, and questionary and InquirerPy are wrappers over it, so improvements here reach a large slice of Python's interactive tooling.

Use it if

  • You are writing a REPL or interactive shell and need tab completion, persistent history, syntax highlighting while typing, and multi-line editing without shelling out to readline
  • You need the prompt to keep working while background tasks print to stdout: patch_stdout redraws the input line instead of letting log output scramble it
  • Your program is already asyncio and you want the prompt inside the same loop, via await session.prompt_async() rather than a blocking call in a thread
  • You want vi and emacs key bindings, named registers, and reverse incremental search for free, on Windows as well as Unix, with no C extension to build
Skip it if

Setup reality

pip install prompt_toolkit pulls only wcwidth, no compiled code, Python 3.10 or newer as of 3.0.53. Two naming quirks bite first: the distribution is prompt-toolkit with a dash but the import is prompt_toolkit with an underscore, and the README still lists Pygments as a dependency while it is actually optional, so PygmentsLexer and PygmentsStyle raise ImportError until you pip install pygments yourself. After that the real cost is conceptual. Anything beyond prompt() means learning buffers, filters, and the layout tree, and the docs assume you read them in order. Calling the blocking prompt() from inside a running asyncio loop raises; you need prompt_async or in_thread=True. And any code that prints from a thread while a prompt is open will corrupt the display unless it runs under patch_stdout.

Patterns

Read a line with editing and history keyssimple-prompt

from prompt_toolkit import prompt

answer = prompt("Give me some input: ")
print(f"You said: {answer}")

This one call already gives emacs keybindings, wide-character handling, and bracketed paste. It needs a real terminal: with stdin piped it raises, so guard with sys.stdin.isatty() in anything that might run headless.

Keep history across prompts and across runssession-with-history

from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory

session = PromptSession(history=FileHistory("~/.myapp_history"))
while True:
    line = session.prompt("> ")
    if line == "exit":
        break

History lives on the session, not on prompt(). Creating a new PromptSession per line, or calling the module-level prompt() in a loop, throws away everything the up arrow should recall.

Add word and nested completiontab-completion

from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter, NestedCompleter
from prompt_toolkit.shortcuts import CompleteStyle

sql = WordCompleter(["select", "from", "where"], ignore_case=True)
prompt("sql> ", completer=sql, complete_while_typing=True)

git = NestedCompleter.from_nested_dict({
    "show": {"log": None, "branch": None},
    "commit": {"--amend": None},
})
prompt("git> ", completer=git,
       complete_style=CompleteStyle.MULTI_COLUMN)

complete_while_typing and a slow completer make every keystroke wait; set it False and let Tab trigger completion when your candidate list comes from a database or the network.

Write a completer that queries live datacustom-completer

from prompt_toolkit.completion import Completer, Completion

class TableCompleter(Completer):
    def get_completions(self, document, complete_event):
        word = document.get_word_before_cursor()
        for name in fetch_table_names():
            if name.startswith(word):
                yield Completion(name, start_position=-len(word),
                                 display_meta="table")

start_position must be negative by the length of the text you are replacing, or the completion gets appended to the partial word instead of overwriting it. This is the single most common completer bug.

Fish-style inline suggestion from historyauto-suggest

from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.history import FileHistory

session = PromptSession(
    history=FileHistory(".hist"),
    auto_suggest=AutoSuggestFromHistory(),
)
session.prompt("> ")   # right arrow accepts the grey suggestion

Pointless without a history object, since there is nothing to suggest from. The default accept key is the right arrow or end; add your own binding if users expect Tab.

Refuse to accept invalid inputinput-validation

from prompt_toolkit import prompt
from prompt_toolkit.validation import Validator

is_number = Validator.from_callable(
    lambda text: text.isdigit(),
    error_message="This input contains non-numeric characters",
    move_cursor_to_end=True,
)
prompt("Give a number: ", validator=is_number,
       validate_while_typing=False)

With validate_while_typing left at True the error message flashes on every partial entry, which reads as broken. Turn it off for anything the user builds up character by character.

Bind your own keyscustom-key-bindings

from prompt_toolkit import prompt
from prompt_toolkit.key_binding import KeyBindings

kb = KeyBindings()

@kb.add("c-t")
def _(event):
    event.app.current_buffer.insert_text("transposed")

@kb.add("escape", "enter")
def _(event):
    event.app.exit(result=event.app.current_buffer.text)

prompt("> ", key_bindings=kb)

Bindings you add are merged with the defaults, so rebinding a key that already does something (c-r for reverse search, c-d for EOF) silently removes that behavior. Multi-key chords are extra arguments to add(), as in add('escape', 'enter').

Print from background threads without wrecking the promptpatch-stdout

import threading, time
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout

def noisy():
    while True:
        print("background event")
        time.sleep(1)

threading.Thread(target=noisy, daemon=True).start()

session = PromptSession()
with patch_stdout():
    session.prompt("> ")

Without patch_stdout, anything written to stdout lands in the middle of the input line and the redraw leaves garbage. It also captures logging handlers that write to stdout, but not ones writing straight to stderr.

Prompt inside a running asyncio loopasync-prompt

import asyncio
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout

async def main():
    session = PromptSession()
    while True:
        with patch_stdout():
            line = await session.prompt_async("> ")
        if line == "quit":
            return

asyncio.run(main())

The blocking prompt() cannot be called from inside a running event loop and will raise. Either use prompt_async or pass in_thread=True to run the blocking version on its own thread.

Color the prompt, right prompt, and bottom toolbarstyled-prompt-and-toolbar

from prompt_toolkit import prompt, HTML
from prompt_toolkit.styles import Style

style = Style.from_dict({
    "user": "#884444",
    "host": "#00aa00 bold",
    "bottom-toolbar": "#ffffff bg:#333333",
})

prompt(
    HTML("<user>joe</user>@<host>host</host> $ "),
    style=style,
    rprompt=HTML("<i>branch: main</i>"),
    bottom_toolbar=lambda: HTML("Press <b>Ctrl-C</b> to quit"),
)

HTML tag names become style class names, so every custom tag needs an entry in the Style dict or it renders unstyled. Pass bottom_toolbar a callable if the text changes; a plain string is evaluated once.

Multi-line input and hidden inputmultiline-and-password

from prompt_toolkit import prompt

text = prompt(
    "> ",
    multiline=True,
    prompt_continuation=lambda width, line_no, wrap: "." * width,
)

pw = prompt("Password: ", is_password=True)

In multiline mode Enter inserts a newline and Meta+Enter (Escape then Enter) submits, which users never guess. Say so in the bottom toolbar or bind your own accept key.

Ready-made pickers and dialog boxeschoice-and-dialogs

from prompt_toolkit.shortcuts import choice, yes_no_dialog, radiolist_dialog

dish = choice(
    message="Please select a dish:",
    options=[("pizza", "Pizza with mushrooms"), ("sushi", "Sushi")],
    default="pizza",
)

ok = yes_no_dialog(title="Confirm", text="Delete it?").run()
pick = radiolist_dialog(title="Pick one",
                        values=[("a", "Alpha"), ("b", "Beta")]).run()

choice() arrived in 3.0.52 and show_numbers became configurable in 3.0.53, so pin accordingly. Dialog shortcuts return a value only after .run(), and as of 3.0.53 their type hints admit None for the cancelled case.

Alternatives

PackageRegistryPick it when
questionaryPyPIYou want select, checkbox, confirm, and text prompts as one-liners; it wraps prompt_toolkit so you can drop down when needed.
textualPyPIYou are building a full-screen app with widgets, panels, and mouse support and want CSS-style layout instead of hand-built containers.
richPyPIYour need is pretty output (tables, progress, tracebacks, markdown) rather than an editable input line.