questionary
Questionary is a Python wrapper around prompt_toolkit for interactive terminal questions. It supplies text, password, path, confirmation, single-select, raw-select, checkbox, autocomplete, and press-any-key prompts, plus forms that collect multiple answers into a dictionary. It adds validation, conditional questions, display labels with separate returned values, styling, synchronous and asynchronous execution, and consistent Ctrl+C handling without requiring callers to assemble prompt_toolkit applications themselves.
Questionary is a good middle layer for polished, human-driven Python prompts without becoming a full terminal UI project. Keep a noninteractive path for automation, handle None after cancellation, and use a real CLI framework around it.
Use it if
- You are building a human-run Python CLI that needs arrows, checkboxes, autocomplete, validation, and styled prompts
- You want several related questions returned as one named answer dictionary
- Your CLI already has an asyncio event loop and needs an awaitable prompt instead of blocking it
- You need display-friendly choice titles while returning stable IDs or Python objects to application code
- The command runs in CI, a cron job, or through redirected input: Questionary is designed for an interactive terminal, so flags, environment variables, or stdin parsing are a better contract
- You need command parsing, help pages, subcommands, and shell completion: Questionary only handles prompts and should be paired with Click, Typer, or argparse
- Accessibility depends on plain line-oriented input or a screen reader: full-screen prompt_toolkit controls, cursor movement, colors, and hidden instructions can be harder than simple input calls
- You want strong static answer types: prompt functions and Question.ask return broad Any-shaped values, while form and dictionary workflows return dynamically keyed dictionaries
- You need checkbox-level validation through the documented Validator interface: the advanced guide explicitly says checkbox does not support the validate parameter
Setup reality
`pip install questionary` installs version 2.1.1 and its only runtime dependency, prompt_toolkit from 2.x or 3.x but below 4. Python 3.9 or newer is required by current package metadata. The first surprise is architectural: Questionary is not a command framework. You still need argparse, Click, or another layer for flags, help text, noninteractive operation, and exit codes. Every builder such as `questionary.text(...)` creates a Question; nothing is shown until `.ask()`, `.unsafe_ask()`, or an async variant runs. Safe Question ask methods catch Ctrl+C, print a cancellation message, and return None; Form.ask catches the same interrupt and returns an empty dictionary. Code must distinguish cancellation from a real answer. Unsafe methods let KeyboardInterrupt propagate when cancellation must abort the process. Interactive terminal behavior depends on prompt_toolkit and a usable TTY; automated tests should patch Questionary at the application boundary or exercise the prompt_toolkit test helpers rather than wait for real keyboard input. If logs or background threads print while a prompt is open, pass `patch_stdout=True` to keep the display from tearing. In async programs use `await question.ask_async()`; calling synchronous `.ask()` blocks the event loop. Choice objects matter when labels and stored values differ, and separators or disabled choices are not returned as answers. Validation runs while the user edits, so slow filesystem or network checks make typing feel broken. Dictionary workflows support `when`, `validate`, and `filter`, but misspelled names remain runtime errors and downstream code receives a plain dictionary. Styling uses prompt_toolkit token names, terminal color support varies, and unspecified tokens fall back to defaults.
Patterns
Ask for validated textask-text
import questionary
name = questionary.text(
"Project name",
validate=lambda value: True if value.strip() else "Name is required",
).ask()
if name is None:
raise SystemExit(130)Safe ask catches Ctrl+C and returns None. Check cancellation before treating the answer as text.
Confirm a destructive actionconfirm-action
import questionary
confirmed = questionary.confirm(
"Delete the generated cache?",
default=False,
).ask()
if confirmed is not True:
print("Cancelled")Use `is True`, because False means rejection while None means the user interrupted the prompt.
Show labels but return stable valuesselect-value
import questionary
from questionary import Choice
region = questionary.select(
"Deployment region",
choices=[
Choice("US East (Virginia)", value="us-east-1"),
Choice("EU West (Ireland)", value="eu-west-1"),
],
).ask()A plain string is both title and value. Choice separates user-facing text from the value stored by the application.
Collect multiple checkbox choicesselect-multiple-values
import questionary
from questionary import Choice
features = questionary.checkbox(
"Enable features",
choices=[
Choice("Audit log", value="audit", checked=True),
Choice("Metrics", value="metrics"),
Choice("Email alerts", value="email"),
],
).ask()The advanced guide says checkbox does not accept Validator validation. Validate the returned list after the prompt if a minimum selection is required.
Prompt for a hidden password with validationask-password
import questionary
password = questionary.password(
"Database password",
validate=lambda value: True if len(value) >= 12 else "Use at least 12 characters",
).ask()Hidden terminal input only prevents shoulder-surfing. Do not log the returned string or persist it in shell history or plain configuration.
Offer autocomplete while allowing searchautocomplete-input
import questionary
environment = questionary.autocomplete(
"Environment",
choices=["development", "staging", "production"],
match_middle=True,
).ask()Autocomplete is interactive terminal UI, not shell completion. Noninteractive invocations still need a flag or environment-variable path.
Prompt for an existing file pathask-file-path
import os
import questionary
config_path = questionary.path(
"Configuration file",
only_files=True,
validate=lambda value: True if os.path.isfile(value) else "File does not exist",
).ask()Path completion does not make the result valid. Validate existence and permissions for the exact operation you will perform.
Collect a typed form into a dictionaryask-form
import questionary
answers = questionary.form(
name=questionary.text("Service name"),
replicas=questionary.text(
"Replica count", default="2",
validate=lambda value: True if value.isdigit() else "Enter a whole number",
),
deploy=questionary.confirm("Deploy now?", default=False),
).ask()
if not answers:
raise SystemExit(130)
answers["replicas"] = int(answers["replicas"])Form keys become dictionary keys. Form.ask returns an empty dictionary on Ctrl+C, unlike a single Question.ask, which returns None.
Ask conditional questions from dictionariesbuild-dynamic-workflow
import questionary
answers = questionary.prompt([
{"type": "confirm", "name": "tls", "message": "Configure TLS?", "default": True},
{
"type": "text",
"name": "hostname",
"message": "Public hostname",
"when": lambda previous: previous.get("tls") is True,
},
])Dictionary names and types are checked at runtime. A skipped question may be absent from the returned answers, so use get for conditional keys.
Skip a question when configuration already existsskip-known-answer
import questionary
port = questionary.text(
"HTTP port", default="8080"
).skip_if(configured_port is not None, default=str(configured_port)).ask()skip_if takes a boolean now, not a callback evaluated later. Its default is returned directly when the condition is true.
Prompt without blocking an asyncio loopask-with-asyncio
import asyncio
import questionary
async def choose_target():
return await questionary.select(
"Target", choices=["dev", "staging", "prod"]
).ask_async(patch_stdout=True)
answer = asyncio.run(choose_target())Use ask_async inside an existing event loop. patch_stdout keeps concurrent log output from corrupting the active prompt.
Apply a reusable terminal stylestyle-prompts
import questionary
from questionary import Style
cli_style = Style([
("qmark", "fg:#5f87ff bold"),
("question", "bold"),
("answer", "fg:#00af87 bold"),
("pointer", "fg:#5f87ff bold"),
("disabled", "fg:#808080 italic"),
])
mode = questionary.select("Mode", ["safe", "fast"], style=cli_style).ask()Terminal color support varies. Unspecified Questionary token classes keep their default styles.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| InquirerPy | PyPI | You want an Inquirer-style prompt set with fuzzy selection and a more feature-heavy interactive workflow |
| prompt-toolkit | PyPI | You need custom terminal applications, buffers, key bindings, and layouts below Questionary's abstraction |
| click | PyPI | You primarily need commands, options, help generation, and simple confirm or prompt calls |
| rich | PyPI | Your main need is formatted terminal output, progress, tables, and status displays rather than forms |