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.
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.
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
- You only need to ask three questions: a select, a confirm, and a text field take ten lines here and one line in questionary or InquirerPy, both of which sit on top of prompt_toolkit anyway
- You are building a full-screen dashboard with panels and widgets: the low-level layout system (containers, filters, FormattedTextControl, Dimension) has a steep learning curve, and textual gives you CSS-like styling and a widget library for that shape of app
- Your program may run without a TTY: it needs a real terminal, so piped stdin, cron jobs, and CI runners need a plain input() fallback or the call fails at runtime
- You want printing and tables rather than input: rich does formatted output far better and prompt_toolkit's output side is deliberately minimal
- You need someone to answer your issue: the tracker sits at 610 open issues (705 counting PRs) and the project is effectively one maintainer, so obscure terminal bugs can age for years
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":
breakHistory 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 suggestionPointless 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
| Package | Registry | Pick it when |
|---|---|---|
| questionary | PyPI | You want select, checkbox, confirm, and text prompts as one-liners; it wraps prompt_toolkit so you can drop down when needed. |
| textual | PyPI | You are building a full-screen app with widgets, panels, and mouse support and want CSS-style layout instead of hand-built containers. |
| rich | PyPI | Your need is pretty output (tables, progress, tracebacks, markdown) rather than an editable input line. |