Intermediate

You have a command-line tool that accepts a dozen options, and right now users pass them all as flags: --env production --region us-east-1 --deploy true. Nobody remembers the exact flag names, typos break things silently, and every new user has to read the docs just to run a basic command. The tool works fine — but actually using it feels like defusing a bomb.

Python’s questionary library solves this by giving you a set of beautifully rendered interactive prompts: arrow-key menus, checkboxes, masked password fields, and confirmation dialogs — all in the terminal, with zero dependencies on a browser or GUI framework. It wraps Python Prompt Toolkit under the hood and handles all the TTY complexity for you. Installation is one command: pip install questionary.

In this article we’ll walk through every major prompt type in questionary — text input, single-select menus, multi-select checkboxes, confirmation dialogs, and password fields. We’ll show how to chain prompts together into a multi-step workflow, apply validation, and then build a real-life project setup wizard that you can adapt for your own CLI tools. By the end you’ll be able to replace any flag-heavy CLI with one that guides users naturally through each choice.

questionary in Python: Quick Example

Here is a self-contained example that shows the three most common prompt types working together. Run it in a terminal (not a Jupyter notebook — questionary requires an interactive TTY).

# quick_example.py
import questionary

name = questionary.text("What is your name?").ask()

env = questionary.select(
    "Which environment?",
    choices=["development", "staging", "production"],
).ask()

confirmed = questionary.confirm(
    f"Deploy {name}'s changes to {env}?", default=False
).ask()

if confirmed:
    print(f"Deploying to {env}...")
else:
    print("Cancelled.")

Output (interactive session in terminal):

? What is your name? Alice
? Which environment? (Use arrow keys)
 > development
   staging
   production
? Deploy Alice's changes to development? (y/N) y
Deploying to development...

The .ask() method blocks until the user answers and returns the value — a string for text/select, a bool for confirm. All three calls are synchronous, which keeps the code easy to read and reason about. For a quick async variant, questionary also provides .ask_async(), but we will stick with the synchronous API throughout this article.

What Is questionary and When Should You Use It?

questionary is a Python library for building interactive terminal menus. It builds on top of prompt_toolkit and provides a clean, high-level API for the most common interactive patterns you would need in a CLI tool. Unlike argparse or click, which require users to know exactly what flags to pass, questionary prompts guide users step by step — making your tools more approachable for teammates who don’t live in the terminal all day.

questionary shines for: onboarding wizards, scaffolding scripts, deployment confirmations, config generators, and any workflow where the user needs to make several choices in sequence. It is not designed for non-interactive scripts (cron jobs, CI pipelines) — in those contexts you would pass values directly as arguments and skip the prompts altogether. You can detect that scenario with sys.stdin.isatty() and fall back to flag-based input.

Prompt TypeMethodReturnsBest For
Free textquestionary.text()strNames, paths, free-form input
Single choicequestionary.select()strPick one from a fixed list
Multiple choicequestionary.checkbox()list[str]Pick any combination
Yes/Noquestionary.confirm()boolDestructive action gating
Passwordquestionary.password()strSecrets, API keys
Pathquestionary.path()strFile/directory selection with tab-complete
Autocompletequestionary.autocomplete()strLong lists with fuzzy filtering

Install questionary with pip before running any of the examples below:

# Install questionary
pip install questionary
Successfully installed questionary-2.0.1 prompt-toolkit-3.0.47 wcwidth-0.2.13
Senior developer pointing at a glowing terminal with interactive CLI menu
argparse: read the docs. questionary: just press the arrow key.

Text Input with Validation

The text() prompt accepts free-form keyboard input. On its own it accepts any string, including an empty one — which is rarely what you want. The validate parameter lets you enforce rules inline. Pass a function that returns True on valid input or an error message string when it fails; questionary will keep re-prompting until the rule passes.

# text_prompt.py
import questionary

def must_not_be_empty(value):
    if not value.strip():
        return "Project name cannot be blank."
    if len(value) > 40:
        return "Keep the project name under 40 characters."
    return True

project_name = questionary.text(
    "Enter project name:",
    validate=must_not_be_empty,
).ask()

print(f"Project: {project_name}")

Output:

? Enter project name:
>> Please enter a valid value  (Project name cannot be blank.)
? Enter project name: my-new-api
Project: my-new-api

The validation function is called on every keystroke as the user types, so feedback is immediate rather than appearing only after they press Enter. If the function returns the string True (not just a truthy value), questionary treats that as valid — so make sure to return the boolean True, not a non-empty string when the value is good. A non-empty string is always treated as an error message.

Single-Choice Menus with select()

The select() prompt renders a scrollable arrow-key menu where the user picks exactly one option. The choices parameter accepts either a plain list of strings or a list of Choice objects if you want to display one label but return a different underlying value.

# select_prompt.py
import questionary
from questionary import Choice

region = questionary.select(
    "Choose a deployment region:",
    choices=[
        Choice("US East (N. Virginia)", value="us-east-1"),
        Choice("EU West (Ireland)",      value="eu-west-1"),
        Choice("Asia Pacific (Sydney)",  value="ap-southeast-2"),
    ],
    use_shortcuts=True,
).ask()

print(f"Selected region slug: {region}")

Output:

? Choose a deployment region: (Use arrow keys)
 > US East (N. Virginia)
   EU West (Ireland)
   Asia Pacific (Sydney)
Selected region slug: us-east-1

Setting use_shortcuts=True assigns number shortcuts (1, 2, 3…) to each choice so power users can skip the arrow key navigation entirely. The value returned by .ask() is always the value field of the selected Choice, not the display label — which means your downstream code works with slugs like us-east-1 rather than human-readable strings that might contain spaces or special characters.

Developer examining an interactive terminal selection menu
Display human labels. Return machine values. Users happy, parser happy.

Multi-Choice Menus with checkbox()

When a user needs to select multiple items from a list, checkbox() is the right tool. Users navigate with arrow keys, toggle individual items with the spacebar, and confirm their full selection by pressing Enter. The return value is always a list — even if only one item is selected.

# checkbox_prompt.py
import questionary

features = questionary.checkbox(
    "Which features should we enable?",
    choices=[
        "Authentication (JWT)",
        "Rate limiting",
        "API documentation (Swagger)",
        "Email notifications",
        "Audit logging",
    ],
).ask()

if not features:
    print("No features selected -- deploying a blank slate.")
else:
    print(f"Enabling: {', '.join(features)}")

Output:

? Which features should we enable? (Use arrow keys, press Space to select, Enter to confirm)
 o Authentication (JWT)
 o Rate limiting
 > o API documentation (Swagger)
 o Email notifications
 o Audit logging
Enabling: Authentication (JWT), API documentation (Swagger)

Always check whether the returned list is empty before iterating over it. Users can press Enter without selecting anything, which gives you an empty list rather than None. If one or more choices should always be pre-selected, pass checked=True inside a Choice object: Choice("Rate limiting", checked=True).

Confirmation Prompts

Before any irreversible action — deleting files, dropping a database, pushing to production — add a confirm() gate. It renders a Y/N prompt and returns a Python bool. The default parameter controls what Enter alone submits: set it to False for destructive operations so that a stray Enter key does not accidentally confirm deletion.

# confirm_prompt.py
import questionary
import sys

target = "production"

proceed = questionary.confirm(
    f"This will wipe all data in {target}. Are you sure?",
    default=False,
).ask()

if not proceed:
    print("Aborted -- no changes made.")
    sys.exit(0)

print(f"Wiping {target}... (not really, this is a demo)")

Output:

? This will wipe all data in production. Are you sure? (y/N) N
Aborted -- no changes made.

The uppercase letter in (y/N) signals the default. With default=False the N is uppercase, meaning Enter without typing anything registers as “No”. Flip it to default=True and the prompt shows (Y/n). This visual cue is standard terminal UX and users familiar with the shell will immediately understand it without reading any instructions.

Password Input

The password() prompt works identically to text() except that every character the user types is echoed as a bullet point (*), keeping secrets off the screen. This is the right choice for API keys, tokens, and passwords in setup wizards — never ask for these via a plain text prompt or a command-line flag (flags end up in shell history).

# password_prompt.py
import questionary

api_key = questionary.password(
    "Enter your API key:",
    validate=lambda val: True if len(val) >= 16 else "API keys are at least 16 characters."
).ask()

# In a real script you would pass this to your client, not print it.
print(f"Key accepted (length {len(api_key)})")

Output:

? Enter your API key: ****************
Key accepted (length 32)

The value returned by .ask() is the raw string the user typed — questionary does not hash it or store it anywhere. Handle it the same way you would any secret: pass it directly to your client library, never log it, and never embed it in error messages.

Energetic developer guarding a vault representing secure password input
Flags go in shell history. Passwords go in questionary.

Chaining Prompts into a Multi-Step Workflow

questionary does not have a built-in form or wizard abstraction — but you do not need one. Python’s own control flow handles branching naturally. Call each .ask() in sequence, use regular if statements to branch based on earlier answers, and collect results in a dict. This pattern is readable, testable, and easy to extend.

# chained_prompts.py
import questionary

answers = {}

answers["name"] = questionary.text("Project name:").ask()

answers["type"] = questionary.select(
    "Project type:",
    choices=["API", "CLI tool", "Data pipeline", "Web app"],
).ask()

if answers["type"] == "API":
    answers["auth"] = questionary.select(
        "Authentication method:",
        choices=["JWT", "OAuth2", "API key", "None"],
    ).ask()
else:
    answers["auth"] = None

answers["ci"] = questionary.confirm("Add GitHub Actions CI?", default=True).ask()

print("\n-- Configuration Summary --")
for key, value in answers.items():
    if value is not None:
        print(f"  {key}: {value}")

Output:

? Project name: payment-service
? Project type: API
? Authentication method: JWT
? Add GitHub Actions CI? (Y/n) Y

-- Configuration Summary --
  name: payment-service
  type: API
  auth: JWT
  ci: True

Notice that the “Authentication method” prompt only appears when the user selects “API” — a conditional prompt that would be awkward to model with command-line flags. The answers dict gives you a clean, serialisable snapshot of the entire session that you can write to a config file, pass to a scaffolding function, or log (minus any sensitive fields) for debugging.

Real-Life Example: Python Project Setup Wizard

Here is a complete CLI wizard that gathers project configuration and writes a minimal pyproject.toml to the current directory. It demonstrates validation, conditional branching, checkbox multi-select, and a final confirmation gate — all the patterns from this article working together.

Senior developer at a wizard workbench building a project configuration wizard
Ten prompts. One config file. Zero forgotten flags.
# project_wizard.py
import questionary
from questionary import Choice
import sys

def run_wizard():
    print("=== Python Project Setup Wizard ===\n")

    name = questionary.text(
        "Package name (lowercase, hyphens ok):",
        validate=lambda v: True if v.replace("-", "").isalnum() and v == v.lower()
                           else "Use lowercase letters and hyphens only.",
    ).ask()

    version = questionary.text("Version:", default="0.1.0").ask()

    python_min = questionary.select(
        "Minimum Python version:",
        choices=["3.9", "3.10", "3.11", "3.12"],
        default="3.11",
    ).ask()

    extras = questionary.checkbox(
        "Include optional tooling:",
        choices=[
            Choice("pytest  (testing)",     value="pytest"),
            Choice("ruff    (linting)",     value="ruff"),
            Choice("mypy    (type checks)", value="mypy"),
            Choice("black   (formatting)",  value="black"),
        ],
    ).ask()

    license_type = questionary.select(
        "License:",
        choices=["MIT", "Apache-2.0", "GPL-3.0", "Proprietary"],
    ).ask()

    confirmed = questionary.confirm(
        f"\nWrite pyproject.toml for '{name}'?", default=True
    ).ask()

    if not confirmed:
        print("Cancelled.")
        sys.exit(0)

    dev_deps = "\n".join(
        f'    "{dep}>=0",' for dep in (extras or [])
    )

    toml_content = f"""[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "{name}"
version = "{version}"
requires-python = ">={python_min}"
license = {{text = "{license_type}"}}

[project.optional-dependencies]
dev = [
{dev_deps}
]
"""

    with open("pyproject.toml", "w") as f:
        f.write(toml_content)

    print(f"\nWrote pyproject.toml for {name} ({version})")
    if extras:
        print(f"Dev tools included: {', '.join(extras)}")

if __name__ == "__main__":
    run_wizard()

Output:

=== Python Project Setup Wizard ===

? Package name (lowercase, hyphens ok): payment-api
? Version: 0.1.0
? Minimum Python version: 3.11
? Include optional tooling: (Space to select)
 > x pytest  (testing)
   o ruff    (linting)
   x mypy    (type checks)
   o black   (formatting)
? License: MIT
? Write pyproject.toml for 'payment-api'? (Y/n) Y

Wrote pyproject.toml for payment-api (0.1.0)
Dev tools included: pytest, mypy

The wizard collects seven pieces of information but only prompts what is relevant — a real advantage over a generic argument parser. To extend it, add a questionary.path() prompt asking where to write the file, or add a select() for the build backend. The entire wizard state lives in local variables, which makes unit-testing the logic straightforward: you can mock .ask() to return fixed values and assert that the generated TOML matches expectations.

Frequently Asked Questions

Why does .ask() return None sometimes?

.ask() returns None when the user presses Ctrl+C to cancel the prompt. Always check for None before using the returned value, or use .ask()‘s raise_keyboard_interrupt=False default behaviour and handle None explicitly. If you prefer an exception on Ctrl+C, pass raise_keyboard_interrupt=True and wrap the call in a try/except KeyboardInterrupt block.

How do I use questionary in CI or non-interactive environments?

questionary requires an interactive TTY. In a CI pipeline where stdin is not a terminal, calls to .ask() will raise an error or hang. The standard pattern is to detect non-interactive mode with sys.stdin.isatty() before running prompts and fall back to reading values from environment variables or command-line arguments. If you need testable prompts, look at questionary’s unsafe_prompt() or use a test runner that can simulate a TTY.

Can I pre-select a default value for select() and text()?

Yes. For text(), pass default="your-default" and the field will be pre-filled — the user can edit it or press Enter to accept. For select(), pass the display string of the option you want pre-highlighted as the default parameter. For checkbox(), pass checked=True inside individual Choice objects to pre-tick specific items.

Can I customise the colours and style of the prompts?

Yes. questionary exposes a style parameter on every prompt that accepts a questionary.Style object built from a list of CSS-like token/colour pairs. Common tokens are question, answer, pointer, selected, and highlighted. Colours can be named ("cyan", "red") or hex strings ("#ff6600"). This is useful for branding internal tools or distinguishing critical prompts visually.

Does questionary support async/await?

Yes — every prompt method has an async counterpart. Instead of .ask(), call await prompt.ask_async() inside an async function. This integrates cleanly with asyncio-based applications. Under the hood, questionary uses prompt_toolkit‘s async event loop, so you do not need to bridge two separate loops. The API is otherwise identical to the synchronous version.

How does questionary compare to PyInquirer or InquirerPy?

All three libraries share the same conceptual origin (JavaScript’s Inquirer.js). questionary has the most Pythonic API — each prompt type is a standalone function rather than a dict config. InquirerPy is more feature-complete (fuzzy search, editor prompts) but has a steeper API surface. PyInquirer is older and less maintained. For most projects, questionary’s balance of simplicity and capability is the right starting point; switch to InquirerPy if you need fuzzy-search menus or editor integration.

Conclusion

We covered the full questionary toolkit: text() for free-form input with inline validation, select() for arrow-key menus with display/value separation, checkbox() for multi-select lists, confirm() for safe destructive-action gates, and password() for keeping secrets off the screen. We also showed how regular Python if statements are all you need to chain prompts into conditional multi-step wizards, and wrapped everything into a project setup tool that writes a pyproject.toml.

A natural extension of the real-life example is to add a questionary.path() prompt for the output directory, integrate with a Cookiecutter template to scaffold full project structures, or call the wizard as a sub-command of a larger click-based CLI. questionary and Click pair very well — Click handles flag parsing for non-interactive use, questionary handles the interactive path, and you branch between them with sys.stdin.isatty().

Full documentation, changelog, and the list of available token names for custom styling are at the official questionary docs: questionary.readthedocs.io. The source is on GitHub at github.com/tmbo/questionary.