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.
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.
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)
1. Learn Python 2. Build a project
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.
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.")
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.
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.")
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.
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))
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.
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()
[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!
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.
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})
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.
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
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.
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
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)
tasks.append(text), then use enumerate(tasks, start=1) to number them in an f-string.
def add_task(tasks, text):
tasks.append(text)
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)
['1. Learn Python', '2. Build a project']
✓ Tasks add and number correctly!
assert tasks == ["Learn Python", "Build a project"], "add_task should append the text"
assert numbered == ["1. Learn Python", "2. Build a project"], "numbered lines should look like 1. Learn Python"
print("✓ Tasks add and number correctly!")