To-Do List CLI

Build a menu-driven to-do app that remembers your tasks between runs. Your first program that saves and loads its own data.

Beginner 45–60 minutes

About this project

A to-do list is the “hello world” of useful tools: a menu, a list of tasks, and the ability to add, view, and remove them. The twist that makes it real is persistence, when you quit and reopen it, your tasks are still there, because the program saves them to a file.

Why it’s worth building: almost every program is some version of “load data, let the user change it, save it back.” A note-taker, a budget tracker, a game’s save system, same shape. Build it once here, with clean functions and a menu loop, and you’ll recognize that shape everywhere.

What you’re building: a menu-driven to-do app that remembers your tasks between runs.

Build it step by step

We’ll build from the inside out: first the data and how to show it, then the menu, then removing items, and finally saving and loading so nothing is ever lost.

Step 1: Hold tasks and show them

Start with a plain list and a function that prints it. enumerate(tasks, start=1) gives each task a human-friendly number, and an early return keeps the “no tasks yet” case from looking broken.

Example
def show_tasks(tasks):
    if not tasks:
        print("(no tasks yet)")
        return
    for number, task in enumerate(tasks, start=1):
        print(f"{number}. {task}")


tasks = ["Learn Python", "Build a project"]
show_tasks(tasks)
Output
1. Learn Python
2. Build a project
A list plus enumerate gives you a clean numbered display.

Step 2: A menu loop that keeps running

The program should keep offering choices until the user quits. A while True loop reads a letter each time and dispatches to the matching action. Normalizing the input with .strip().lower() means “ A ”, “a”, and “A” all work.

Example
def add_task(tasks):
    text = input("New task: ").strip()
    if text:
        tasks.append(text)
        print(f"Added: {text}")
    else:
        print("Nothing to add.")


tasks = []
menu = "\n[a]dd  [l]ist  [q]uit\n> "
while True:
    choice = input(menu).strip().lower()
    if choice == "a":
        add_task(tasks)
    elif choice == "l":
        show_tasks(tasks)
    elif choice == "q":
        break
    else:
        print("Use a, l, or q.")
The menu loop: read a choice, act on it, repeat until quit.

Step 3: Remove a task safely

Removing means turning a number the user types into a list index, and people will type 9 when there are three tasks, or banana. We convert with int() inside try/except and bounds-check before touching the list.

Example
def remove_task(tasks):
    show_tasks(tasks)
    raw = input("Remove which number? ").strip()
    try:
        index = int(raw) - 1   # humans count from 1, lists from 0
    except ValueError:
        print("Please type a number.")
        return
    if 0 <= index < len(tasks):
        removed = tasks.pop(index)
        print(f"Removed: {removed}")
    else:
        print("No task with that number.")
Validate the number, bounds-check the index, then pop, never before.

Step 4: Save on quit, load on start

This is the step that makes it a tool. pathlib reads and writes the whole file in one call each. We store one task per line; splitlines() turns the file back into a list, and we skip blank lines so a stray newline never becomes an empty task.

Example
from pathlib import Path

TASKS_FILE = Path("tasks.txt")


def load_tasks():
    if TASKS_FILE.exists():
        return [line for line in TASKS_FILE.read_text().splitlines() if line]
    return []


def save_tasks(tasks):
    TASKS_FILE.write_text("\n".join(tasks))
Read on startup, write on quit — persistence in two small functions.

The finished app

Everything together: it loads your tasks on launch, runs the menu, and saves on the way out. Each action is its own function, so the main() loop reads almost like plain English.

Example · todo.py
from pathlib import Path

TASKS_FILE = Path("tasks.txt")


def load_tasks():
    """Read saved tasks, one per line (empty list if there's no file yet)."""
    if TASKS_FILE.exists():
        return [line for line in TASKS_FILE.read_text().splitlines() if line]
    return []


def save_tasks(tasks):
    TASKS_FILE.write_text("\n".join(tasks))


def show_tasks(tasks):
    if not tasks:
        print("(no tasks yet)")
        return
    for number, task in enumerate(tasks, start=1):
        print(f"{number}. {task}")


def add_task(tasks):
    text = input("New task: ").strip()
    if text:
        tasks.append(text)
        print(f"Added: {text}")
    else:
        print("Nothing to add.")


def remove_task(tasks):
    show_tasks(tasks)
    raw = input("Remove which number? ").strip()
    try:
        index = int(raw) - 1
    except ValueError:
        print("Please type a number.")
        return
    if 0 <= index < len(tasks):
        print(f"Removed: {tasks.pop(index)}")
    else:
        print("No task with that number.")


def main():
    tasks = load_tasks()
    menu = "\n[a]dd  [l]ist  [r]emove  [q]uit\n> "
    while True:
        choice = input(menu).strip().lower()
        if choice == "a":
            add_task(tasks)
        elif choice == "l":
            show_tasks(tasks)
        elif choice == "r":
            remove_task(tasks)
        elif choice == "q":
            save_tasks(tasks)
            print("Saved. Bye!")
            break
        else:
            print("Unknown option, use a, l, r, or q.")


if __name__ == "__main__":
    main()
Output
[a]dd  [l]ist  [r]emove  [q]uit
> a
New task: Learn Python
Added: Learn Python

[a]dd  [l]ist  [r]emove  [q]uit
> l
1. Learn Python

[a]dd  [l]ist  [r]emove  [q]uit
> q
Saved. Bye!
A sample session. Quit, reopen it, and “Learn Python” is still there.

Keep going, make it your own

The bones are solid. These upgrades each teach a new idea while making the app genuinely more useful.

Mark tasks done instead of deleting them. Store each task as a dictionary like {'text': 'Buy milk', 'done': False}, then save with JSON so the structure survives. JSON is built for exactly this kind of nested data.

Example
import json
from pathlib import Path

TASKS_FILE = Path("tasks.json")


def load_tasks():
    if TASKS_FILE.exists():
        return json.loads(TASKS_FILE.read_text())
    return []


def save_tasks(tasks):
    TASKS_FILE.write_text(json.dumps(tasks, indent=2))


# a task is now a dict, so it can carry a done flag, a date, a priority...
tasks.append({"text": "Buy milk", "done": False})
Switching from plain strings to dicts unlocks done-status, dates, and more.

Add a task straight from the command line. Reading sys.argv lets you run python todo.py "Buy milk" and add a task without opening the menu, perfect for a quick capture.

Example
import sys

# python todo.py "Buy milk"  ->  add and exit, skipping the menu
if len(sys.argv) > 1:
    tasks = load_tasks()
    tasks.append(" ".join(sys.argv[1:]))
    save_tasks(tasks)
    print("Task added.")
    raise SystemExit
Joining sys.argv[1:] lets the task be several words without quotes fights.

Keep the file in a real app folder. Right now tasks.txt lands wherever you run the program. Store it under your home directory instead, and create that folder automatically with pathlib, the same trick real apps use for their settings.

Example
from pathlib import Path

app_dir = Path.home() / ".todo"
app_dir.mkdir(exist_ok=True)         # make ~/.todo if it doesn't exist
TASKS_FILE = app_dir / "tasks.txt"  # always the same place, every run
Path.home() + mkdir(exist_ok=True) gives the app one reliable home.

Download the files

The finished app from this build. Run it with python todo.py; it creates tasks.txt next to itself.


Mini exercise (easy)

Build the heart of the app: complete add_task(tasks, text) so it appends the task and returns the list, then produce a numbered display of all tasks.

Give it a shot. Complete the code and press Run; there’s nothing to download or configure.

def add_task(tasks, text):
    # add text to the list
    return tasks

tasks = []
add_task(tasks, "Learn Python")
add_task(tasks, "Build a project")

numbered = [f"{i}. {t}" for i, t in enumerate(tasks, start=1)]
print(numbered)

Where to go next

You’ve now written and read files, which is the doorway to real automation. The CSV Cleaner takes that further: a command-line tool that reads a data file, transforms it, and writes a clean copy back out.