Project: TODO List CLI (with Persistence)
1 · The lesson
readYou're going to build a command-line TODO list — add tasks, list them, mark them done, delete them. It will remember tasks across runs by saving to a file.
This is the first project with persistence. The shape of this program (load state → loop → save state) is the same shape as ~80% of real-world Python scripts.
What you'll practice: lists, functions, file I/O, JSON, exception handling, dispatch dictionaries.
Step 1 — In-Memory Version
Start without persistence. Get the data structure right first.
todos = [] # a list of tasks def add(task): todos.append({"text": task, "done": False}) print(f"+ added: {task}") def show(): if not todos: print("(no tasks)") return for i, t in enumerate(todos, start=1): marker = "✓" if t["done"] else " " print(f" {i}. [{marker}] {t['text']}") def complete(index): if 1 <= index <= len(todos): todos[index - 1]["done"] = True print(f"✓ done: {todos[index - 1]['text']}") else: print(f"⚠️ no task #{index}") def delete(index): if 1 <= index <= len(todos): removed = todos.pop(index - 1) print(f"× deleted: {removed['text']}") else: print(f"⚠️ no task #{index}") # Drive it add("Buy groceries") add("Write blog post") add("Call mom") show() complete(1) delete(2) show()
Each task is a dict with text and done — small but expressive. Lists of dicts is the most common shape in beginner Python.
Note enumerate(todos, start=1) — counts from 1 instead of 0, friendlier for humans.
Step 2 — Add a Command Loop
Instead of calling functions directly, accept user commands.
todos = [] def parse_command(raw): """Return (command, argument_str). Tolerant of leading/trailing spaces.""" raw = raw.strip() if not raw: return "", "" parts = raw.split(maxsplit=1) cmd = parts[0].lower() arg = parts[1] if len(parts) > 1 else "" return cmd, arg # Simulated commands (in a real terminal you'd use input() in a while loop) commands = [ "add Buy groceries", "add Write blog post", "add Call mom", "list", "done 1", "delete 2", "list", ] for raw in commands: cmd, arg = parse_command(raw) print(f"\n> {raw}") if cmd == "add": todos.append({"text": arg, "done": False}) print(f"+ {arg}") elif cmd == "list": if not todos: print("(no tasks)") for i, t in enumerate(todos, start=1): marker = "✓" if t["done"] else " " print(f" {i}. [{marker}] {t['text']}") elif cmd == "done": idx = int(arg) todos[idx - 1]["done"] = True elif cmd == "delete": idx = int(arg) todos.pop(idx - 1) elif cmd in ("quit", "exit"): break else: print(f"⚠️ unknown command: {cmd}")
In a real terminal, swap the for raw in commands: loop for:
# while True: # raw = input("> ") # cmd, arg = parse_command(raw) # if cmd in ("quit", "exit"): # break # # ... same dispatch ...
Step 3 — Persist to a File (JSON)
Right now the tasks disappear every time the program ends. Let's save and load.
We'll use JSON (JavaScript Object Notation) — it's a text format that's perfect for lists-of-dicts, and Python has it built-in.
import json import io # (just for the browser simulation — see note below) # In real code, this is a real file path: # TODO_FILE = "todos.json" # In the browser sandbox we keep it in memory: fake_storage = io.StringIO('[]') def load_todos(): """Read the todo file. Return [] if it doesn't exist or is corrupt.""" try: fake_storage.seek(0) return json.loads(fake_storage.read()) except (FileNotFoundError, json.JSONDecodeError): return [] def save_todos(todos): """Write the todo list as JSON, prettily indented.""" fake_storage.seek(0) fake_storage.truncate() fake_storage.write(json.dumps(todos, indent=2)) # The real-file version is just: # # def load_todos(): # try: # with open("todos.json", "r", encoding="utf-8") as f: # return json.load(f) # except (FileNotFoundError, json.JSONDecodeError): # return [] # # def save_todos(todos): # with open("todos.json", "w", encoding="utf-8") as f: # json.dump(todos, f, indent=2) todos = load_todos() todos.append({"text": "Buy groceries", "done": False}) todos.append({"text": "Write blog post", "done": False}) save_todos(todos) # Pretend the program restarted — load again reloaded = load_todos() print(reloaded)
The pattern: load at startup, save after every change (or at clean exit, depending on how paranoid you want to be about crashes).
Why JSON over plain text: JSON keeps the structure. We need to remember done: True/False per task. A plain text file would need extra parsing logic. JSON gives us back exactly the same shape we saved.
Step 4 — Putting It All Together
The full program:
import json import io # Browser-sandbox storage (real version uses a real file) fake_storage = io.StringIO('[]') def load_todos(): try: fake_storage.seek(0) return json.loads(fake_storage.read()) except (FileNotFoundError, json.JSONDecodeError): return [] def save_todos(todos): fake_storage.seek(0) fake_storage.truncate() fake_storage.write(json.dumps(todos, indent=2)) def show(todos): if not todos: print("(no tasks)") return for i, t in enumerate(todos, start=1): marker = "✓" if t["done"] else " " print(f" {i}. [{marker}] {t['text']}") def parse_command(raw): raw = raw.strip() if not raw: return "", "" parts = raw.split(maxsplit=1) return parts[0].lower(), parts[1] if len(parts) > 1 else "" def run(commands): todos = load_todos() for raw in commands: cmd, arg = parse_command(raw) try: if cmd == "add": if not arg: print("⚠️ add what?") continue todos.append({"text": arg, "done": False}) save_todos(todos) print(f"+ {arg}") elif cmd == "list": show(todos) elif cmd == "done": idx = int(arg) todos[idx - 1]["done"] = True save_todos(todos) print(f"✓ task {idx} done") elif cmd == "delete": idx = int(arg) removed = todos.pop(idx - 1) save_todos(todos) print(f"× deleted: {removed['text']}") elif cmd in ("quit", "exit"): break else: print(f"⚠️ unknown: {cmd}") except (ValueError, IndexError): print(f"⚠️ bad argument for '{cmd}': {arg!r}") # Demo session session = [ "add Buy groceries", "add Write blog post", "add Call mom", "list", "done 1", "delete 2", "list", ] run(session)
That's a complete, useful program in ~50 lines. It validates input, persists state, handles bad arguments, and has a clear command vocabulary.
Stretch Goals
1. Due dates: extend each task to {"text": ..., "done": ..., "due": "2026-06-01"}. Sort by date when listing.
2. Categories / tags: support add @work Buy printer paper. List by tag.
3. Search: find blog shows all matching tasks.
4. Undo: keep a history of the last 5 states. undo reverts.
5. Priority: add !high Pay rent. Sort priorities at the top.
6. Export: export todos.txt writes a plain-text version anyone can read.
🎯 Your Turn — Add a "search" Command
Extend the dispatch with a search <term> command that prints only tasks whose text contains the search term (case-insensitive).
todos = [
{"text": "Buy groceries", "done": False},
{"text": "Write blog post", "done": True},
{"text": "Call mom", "done": False},
{"text": "Buy printer paper", "done": False},
]
def search_todos(todos, term):
# TODO: return a list of tasks whose 'text' contains `term` (case-insensitive)
# Hint: use a list comprehension and .lower()
pass
# Test
results = search_todos(todos, "buy")
for t in results:
print(t)
# Should print both "Buy groceries" and "Buy printer paper"Hint 1 — Case-insensitive contains
term.lower() in t["text"].lower() — lowercase both sides, then use Python's in operator.
Hint 2 — List comprehension
[t for t in todos if <condition>] — keep only the items that match.
Show full solution
def search_todos(todos, term): q = term.lower() return [t for t in todos if q in t["text"].lower()] # Hooking it into the dispatch loop (excerpt): # elif cmd == "search": # matches = search_todos(todos, arg) # if not matches: # print(f"(no matches for '{arg}')") # for t in matches: # marker = "✓" if t["done"] else " " # print(f" [{marker}] {t['text']}") print(search_todos([ {"text": "Buy groceries", "done": False}, {"text": "Write blog post", "done": True}, {"text": "Buy printer paper", "done": False}, ], "buy"))
What You Learned
- List of dicts as the canonical "table of records" data structure
- A dispatch loop (parse command → run the matching branch) — the shape of every CLI tool
enumerate(seq, start=1)for human-friendly numbering- JSON persistence — load → mutate → save
try/exceptaround the whole dispatch so one bad command doesn't crash the program
You can now write small CLI tools that remember things across runs. That's a huge step up — most useful Python scripts are exactly this shape.
Next: Password Generator.