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 recognise that shape everywhere.

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. Normalising 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.

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.

Do it here. Finish the code below and press Run to try your answer right in the browser — no setup needed.

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.