Build a CLI To-Do App in Python

· 6 min read · Updated March 23, 2026 · beginner
python cli argparse

When you work in a terminal often, command-line tools become second nature. Python makes it easy to build your own CLI applications using the standard library’s argparse module. In this guide you will build a working to-do app that runs entirely in the terminal, stores tasks in a JSON file, and introduces you to the key concepts of CLI development in Python.

What You Will Build

A CLI to-do app called todo with these commands:

  • todo add <task> — add a new task
  • todo list — show all tasks
  • todo done <id> — mark a task as done
  • todo delete <id> — remove a task

The app stores tasks in ~/.todo.json so your data persists between sessions.

Setting Up the Project

Create a single file named todo.py. No external libraries are needed — everything comes from Python’s standard library.

import argparse
import json
import sys
from pathlib import Path

The argparse module handles command-line parsing. The json module reads and writes the task file. pathlib.Path gives you a cross-platform way to build the path to your data file.

Creating the Argument Parser

Every CLI app starts with an ArgumentParser object. This is the foundation that receives and interprets command-line input.

def create_parser():
    parser = argparse.ArgumentParser(
        description="Manage your to-do list from the command line."
    )
    return parser

ArgumentParser automatically generates --help output:

# python todo.py --help
# output: usage: todo [-h]
#         Manage your to-do list from the command line.
#
#         options:
#           -h, --help  show this help message and exit

Run this to confirm the parser works before adding subcommands.

Adding Subcommands

A to-do app needs multiple commands. You use add_subparsers() to create nested command groups — one for each action the app can perform.

def create_parser():
    parser = argparse.ArgumentParser(
        description="Manage your to-do list from the command line."
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    # 'add' subcommand
    add_parser = subparsers.add_parser("add", help="Add a new task")
    add_parser.add_argument("task", help="The task description")

    # 'list' subcommand
    list_parser = subparsers.add_parser("list", help="Show all tasks")

    # 'done' subcommand
    done_parser = subparsers.add_parser("done", help="Mark a task as done")
    done_parser.add_argument("id", type=int, help="Task ID to mark as done")

    # 'delete' subcommand
    delete_parser = subparsers.add_parser("delete", help="Delete a task")
    delete_parser.add_argument("id", type=int, help="Task ID to delete")

    return parser

The dest="command" argument stores the chosen subcommand name in args.command. Setting required=True ensures Python exits with a clear error if the user runs todo.py without any command.

Notice that add takes a positional argument for the task description, while done and delete both take an id with type=int. The type=int conversion is important — if the user types todo done hello, argparse prints a helpful error instead of crashing.

Loading and Saving Tasks

The tasks live in a JSON file at ~/.todo.json. The pathlib module builds this path in a way that works on Linux, macOS, and Windows.

DATA_FILE = Path.home() / ".todo.json"

def load_tasks():
    if not DATA_FILE.exists():
        return []
    with open(DATA_FILE, "r") as f:
        return json.load(f)

def save_tasks(tasks):
    with open(DATA_FILE, "w") as f:
        json.dump(tasks, f, indent=2)

load_tasks returns an empty list if the file does not exist yet. save_tasks writes the full task list back with indentation for human readability. The indent=2 formatting means if you open ~/.todo.json in a text editor, you can read it clearly.

Handling Each Command

With the parser in place and tasks loading from disk, you can implement each command as a separate function.

def cmd_add(args):
    tasks = load_tasks()
    # IDs stay sequential because cmd_delete re-numbers remaining tasks after each deletion
    task_id = len(tasks) + 1
    tasks.append({"id": task_id, "task": args.task, "done": False})
    save_tasks(tasks)
    print(f"Added task {task_id}: {args.task}")

def cmd_list(args):
    tasks = load_tasks()
    if not tasks:
        print("No tasks yet. Add one with: todo add <task>")
        return
    for t in tasks:
        status = "[x]" if t["done"] else "[ ]"
        print(f"{t['id']}. {status} {t['task']}")

def cmd_done(args):
    tasks = load_tasks()
    for t in tasks:
        if t["id"] == args.id:
            t["done"] = True
            save_tasks(tasks)
            print(f"Task {args.id} marked as done.")
            return
    print(f"Task {args.id} not found.")

def cmd_delete(args):
    tasks = load_tasks()
    new_tasks = [t for t in tasks if t["id"] != args.id]
    if len(new_tasks) == len(tasks):
        print(f"Task {args.id} not found.")
        return
    # Re-number remaining tasks to avoid gaps
    for i, t in enumerate(new_tasks, start=1):
        t["id"] = i
    save_tasks(new_tasks)
    print(f"Task {args.id} deleted.")

cmd_delete rebuilds the task list without the deleted item and re-numbers all remaining IDs so gaps do not accumulate. For example, if you delete task 2, what was task 3 becomes task 2.

Wiring It All Together

The main function ties the pieces together: parse the arguments, dispatch to the right handler based on args.command.

def main():
    parser = create_parser()
    args = parser.parse_args()

    if args.command == "add":
        cmd_add(args)
    elif args.command == "list":
        cmd_list(args)
    elif args.command == "done":
        cmd_done(args)
    elif args.command == "delete":
        cmd_delete(args)

if __name__ == "__main__":
    main()

Running the App

Make the file executable and place it somewhere on your PATH, or run it directly with Python:

# Add a task
$ python todo.py add Buy groceries
# output: Added task 1: Buy groceries

# Add another task
$ python todo.py add Walk the dog
# output: Added task 2: Walk the dog

# List all tasks
$ python todo.py list
# output: 1. [ ] Buy groceries
#         2. [ ] Walk the dog

# Mark task 1 as done
$ python todo.py done 1
# output: Task 1 marked as done.

# Delete task 2
$ python todo.py delete 2
# output: Task 2 deleted.

# List again
$ python todo.py list
# output: 1. [x] Buy groceries

The ~/.todo.json file after these operations looks like this:

[
  {"id": 1, "task": "Buy groceries", "done": true}
]

How It Works

argparse builds a parser from the description you provide, then inspects sys.argv (the raw command-line input) and converts it into an args object with attributes matching each argument you defined.

When you call add_subparsers(), each subparser gets its own argument set. The dest="command" argument tells argparse to store the chosen subcommand name as args.command. Your code then uses that value to call the right function.

The json module reads the entire task list into memory as a Python list of dictionaries. When you save, you write the whole list back. For a small personal tool this approach is simple and sufficient. Larger applications might use a database instead.

Extending the App

Once the basic app works, a few additions make it more useful:

Adding due dates. Add a due field to each task dictionary and extend cmd_add to accept a --due argument using add_argument("--due", help="Due date in YYYY-MM-DD format").

Filtering by status. Modify cmd_list to accept --all, --done, or --pending flags so you can focus on incomplete tasks.

Editing a task. Add an edit subcommand with add_argument("id", type=int) and add_argument("new_task", help="New task description").

Each extension follows the same pattern: define arguments on a subparser, read the current state, modify the data, save it back.

See Also

  • The argparse module — dive deeper into subparsers, argument types, and custom actions for building richer CLI tools
  • Working with modules — understand how import and from ... import work to use the standard library effectively
  • Built-in functions — explore functions like len(), open(), and enumerate() that power CLI data handling
  • String methods — format task output, check user input, and manipulate text in your CLI app